From a28f4ccfe1a010e092fa8b2febad55ec46a385cb Mon Sep 17 00:00:00 2001
From: Carl Vitullo <vcarl@users.noreply.github.com>
Date: Wed, 15 Nov 2023 12:07:17 -0500
Subject: [PATCH] Add a resume review AI command (#332)

* Add OpenAI SDK and API key

* Functioning AI resume review

* Include prompt in code

* Add description

* Log responses so we can review them more easily
---
 .env.example                  |   1 +
 .github/workflows/node.js.yml |   1 +
 cluster/deployment.yaml       |   5 +
 package-lock.json             | 358 +++++++++++++++++++++++++++++++---
 package.json                  |   1 +
 src/constants/channels.ts     |   2 +
 src/features/resume.ts        | 179 +++++++++++++++++
 src/helpers/env.ts            |   1 +
 src/index.ts                  |   2 +
 9 files changed, 523 insertions(+), 27 deletions(-)
 create mode 100644 src/features/resume.ts

diff --git a/.env.example b/.env.example
index 8ca51ef0..644e10b3 100644
--- a/.env.example
+++ b/.env.example
@@ -4,3 +4,4 @@ DISCORD_APP_ID=<required>
 GUILD_ID=614601782152265748
 GITHUB_TOKEN=
 AMPLITUDE_KEY=
+OPENAI_KEY=
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index eaf898a7..571c8a79 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -90,4 +90,5 @@ jobs:
             --from-literal=GUILD_ID=${{ secrets.GUILD_ID }} \
             --from-literal=GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} \
             --from-literal=AMPLITUDE_KEY=${{ secrets.AMPLITUDE_KEY }} || echo \n
+            --from-literal=OPENAI_KEY=${{ secrets.OPENAI_KEY }} || echo \n
           kubectl apply -k .
diff --git a/cluster/deployment.yaml b/cluster/deployment.yaml
index 9b09e8a9..a54ba95d 100644
--- a/cluster/deployment.yaml
+++ b/cluster/deployment.yaml
@@ -48,3 +48,8 @@ spec:
                 secretKeyRef:
                   name: reactibot-env
                   key: GUILD_ID
+            - name: OPENAI_KEY
+              valueFrom:
+                secretKeyRef:
+                  name: reactibot-env
+                  key: OPENAI_KEY
diff --git a/package-lock.json b/package-lock.json
index 8828b66e..891c62e9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
         "node-cron": "^3.0.0",
         "node-fetch": "^2.6.7",
         "open-graph-scraper": "^4.11.0",
+        "openai": "^4.17.3",
         "pretty-bytes": "^5.6.0",
         "query-string": "^6.2.0",
         "uuid": "^8.3.2"
@@ -386,12 +387,12 @@
       "license": "MIT"
     },
     "node_modules/@types/node-fetch": {
-      "version": "2.6.1",
-      "dev": true,
-      "license": "MIT",
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.9.tgz",
+      "integrity": "sha512-bQVlnMLFJ2d35DkPNjEPmd9ueO/rh5EiaZt2bhqiSarPjZIuIV6bPQVqcrEyvNo+AfTrRGVazle1tl597w3gfA==",
       "dependencies": {
         "@types/node": "*",
-        "form-data": "^3.0.0"
+        "form-data": "^4.0.0"
       }
     },
     "node_modules/@types/open-graph-scraper": {
@@ -655,6 +656,17 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
     "node_modules/acorn": {
       "version": "8.8.2",
       "dev": true,
@@ -682,6 +694,17 @@
         "node": ">=0.4.0"
       }
     },
+    "node_modules/agentkeepalive": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
+      "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
     "node_modules/ajv": {
       "version": "6.12.6",
       "dev": true,
@@ -759,14 +782,19 @@
     },
     "node_modules/asynckit": {
       "version": "0.4.0",
-      "dev": true,
-      "license": "MIT"
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
     },
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/base-64": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
+      "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA=="
+    },
     "node_modules/binary-extensions": {
       "version": "2.2.0",
       "dev": true,
@@ -896,6 +924,14 @@
       "version": "1.4.0",
       "license": "MIT"
     },
+    "node_modules/charenc": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+      "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/check-error": {
       "version": "1.0.2",
       "dev": true,
@@ -1016,8 +1052,8 @@
     },
     "node_modules/combined-stream": {
       "version": "1.0.8",
-      "dev": true,
-      "license": "MIT",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
       "dependencies": {
         "delayed-stream": "~1.0.0"
       },
@@ -1059,6 +1095,14 @@
         "node": ">= 8"
       }
     },
+    "node_modules/crypt": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+      "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/css-select": {
       "version": "5.1.0",
       "license": "BSD-2-Clause",
@@ -1180,8 +1224,8 @@
     },
     "node_modules/delayed-stream": {
       "version": "1.0.0",
-      "dev": true,
-      "license": "MIT",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
       "engines": {
         "node": ">=0.4.0"
       }
@@ -1194,6 +1238,15 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/digest-fetch": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz",
+      "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==",
+      "dependencies": {
+        "base-64": "^0.1.0",
+        "md5": "^2.3.0"
+      }
+    },
     "node_modules/dir-glob": {
       "version": "3.0.1",
       "dev": true,
@@ -1631,6 +1684,14 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "license": "MIT"
@@ -1742,9 +1803,9 @@
       "license": "ISC"
     },
     "node_modules/form-data": {
-      "version": "3.0.1",
-      "dev": true,
-      "license": "MIT",
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
       "dependencies": {
         "asynckit": "^0.4.0",
         "combined-stream": "^1.0.8",
@@ -1754,6 +1815,31 @@
         "node": ">= 6"
       }
     },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
+    "node_modules/formdata-node/node_modules/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
     "node_modules/fs.realpath": {
       "version": "1.0.0",
       "dev": true,
@@ -2078,6 +2164,14 @@
         "node": ">=10.19.0"
       }
     },
+    "node_modules/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "dependencies": {
+        "ms": "^2.0.0"
+      }
+    },
     "node_modules/iconv-lite": {
       "version": "0.4.24",
       "license": "MIT",
@@ -2211,6 +2305,11 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/is-buffer": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
+    },
     "node_modules/is-callable": {
       "version": "1.2.4",
       "dev": true,
@@ -2513,6 +2612,16 @@
       "dev": true,
       "license": "ISC"
     },
+    "node_modules/md5": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+      "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+      "dependencies": {
+        "charenc": "0.0.2",
+        "crypt": "0.0.2",
+        "is-buffer": "~1.1.6"
+      }
+    },
     "node_modules/memorystream": {
       "version": "0.3.1",
       "dev": true,
@@ -2542,16 +2651,16 @@
     },
     "node_modules/mime-db": {
       "version": "1.52.0",
-      "dev": true,
-      "license": "MIT",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
       "engines": {
         "node": ">= 0.6"
       }
     },
     "node_modules/mime-types": {
       "version": "2.1.35",
-      "dev": true,
-      "license": "MIT",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
       "dependencies": {
         "mime-db": "1.52.0"
       },
@@ -2642,6 +2751,24 @@
         "node": ">=6.0.0"
       }
     },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
     "node_modules/node-fetch": {
       "version": "2.6.7",
       "license": "MIT",
@@ -2992,6 +3119,33 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/openai": {
+      "version": "4.17.3",
+      "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.3.tgz",
+      "integrity": "sha512-Gx9wzl9HWX5pjagkgXVu6U2BTFEPkQFdkppNnAX2n2Rpjtn2zt152wXh7NnZ5eJuVxUGYzRe66JmayAEGjzqAg==",
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "digest-fetch": "^1.3.0",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7",
+        "web-streams-polyfill": "^3.2.1"
+      },
+      "bin": {
+        "openai": "bin/cli"
+      }
+    },
+    "node_modules/openai/node_modules/@types/node": {
+      "version": "18.18.9",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.9.tgz",
+      "integrity": "sha512-0f5klcuImLnG4Qreu9hPj/rEfFq6YRc5n2mAjSsH+ec/mJL+3voBH0+8T7o8RpFjH7ovc+TRsL/c7OYIQsPTfQ==",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.1",
       "dev": true,
@@ -4147,6 +4301,11 @@
         "node": ">=14.0"
       }
     },
+    "node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+    },
     "node_modules/uri-js": {
       "version": "4.4.1",
       "dev": true,
@@ -4334,6 +4493,14 @@
         }
       }
     },
+    "node_modules/web-streams-polyfill": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
+      "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
     "node_modules/webidl-conversions": {
       "version": "3.0.1",
       "license": "BSD-2-Clause"
@@ -4717,11 +4884,12 @@
       "version": "3.0.1"
     },
     "@types/node-fetch": {
-      "version": "2.6.1",
-      "dev": true,
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.9.tgz",
+      "integrity": "sha512-bQVlnMLFJ2d35DkPNjEPmd9ueO/rh5EiaZt2bhqiSarPjZIuIV6bPQVqcrEyvNo+AfTrRGVazle1tl597w3gfA==",
       "requires": {
         "@types/node": "*",
-        "form-data": "^3.0.0"
+        "form-data": "^4.0.0"
       }
     },
     "@types/open-graph-scraper": {
@@ -4875,6 +5043,14 @@
       "version": "1.1.1",
       "dev": true
     },
+    "abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "requires": {
+        "event-target-shim": "^5.0.0"
+      }
+    },
     "acorn": {
       "version": "8.8.2",
       "dev": true
@@ -4888,6 +5064,14 @@
       "version": "8.2.0",
       "dev": true
     },
+    "agentkeepalive": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
+      "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==",
+      "requires": {
+        "humanize-ms": "^1.2.1"
+      }
+    },
     "ajv": {
       "version": "6.12.6",
       "dev": true,
@@ -4935,12 +5119,18 @@
     },
     "asynckit": {
       "version": "0.4.0",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
     },
     "balanced-match": {
       "version": "1.0.2",
       "dev": true
     },
+    "base-64": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz",
+      "integrity": "sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA=="
+    },
     "binary-extensions": {
       "version": "2.2.0",
       "dev": true
@@ -5025,6 +5215,11 @@
     "chardet": {
       "version": "1.4.0"
     },
+    "charenc": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
+      "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
+    },
     "check-error": {
       "version": "1.0.2",
       "dev": true
@@ -5103,7 +5298,8 @@
     },
     "combined-stream": {
       "version": "1.0.8",
-      "dev": true,
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
       "requires": {
         "delayed-stream": "~1.0.0"
       }
@@ -5132,6 +5328,11 @@
         "which": "^2.0.1"
       }
     },
+    "crypt": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
+      "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
+    },
     "css-select": {
       "version": "5.1.0",
       "requires": {
@@ -5193,12 +5394,22 @@
     },
     "delayed-stream": {
       "version": "1.0.0",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
     },
     "diff": {
       "version": "4.0.2",
       "dev": true
     },
+    "digest-fetch": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/digest-fetch/-/digest-fetch-1.3.0.tgz",
+      "integrity": "sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==",
+      "requires": {
+        "base-64": "^0.1.0",
+        "md5": "^2.3.0"
+      }
+    },
     "dir-glob": {
       "version": "3.0.1",
       "dev": true,
@@ -5494,6 +5705,11 @@
       "version": "2.0.3",
       "dev": true
     },
+    "event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
+    },
     "fast-deep-equal": {
       "version": "3.1.3"
     },
@@ -5572,14 +5788,36 @@
       "dev": true
     },
     "form-data": {
-      "version": "3.0.1",
-      "dev": true,
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
       "requires": {
         "asynckit": "^0.4.0",
         "combined-stream": "^1.0.8",
         "mime-types": "^2.1.12"
       }
     },
+    "form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="
+    },
+    "formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "requires": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "dependencies": {
+        "web-streams-polyfill": {
+          "version": "4.0.0-beta.3",
+          "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+          "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="
+        }
+      }
+    },
     "fs.realpath": {
       "version": "1.0.0",
       "dev": true
@@ -5773,6 +6011,14 @@
         "resolve-alpn": "^1.0.0"
       }
     },
+    "humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "requires": {
+        "ms": "^2.0.0"
+      }
+    },
     "iconv-lite": {
       "version": "0.4.24",
       "requires": {
@@ -5850,6 +6096,11 @@
         "has-tostringtag": "^1.0.0"
       }
     },
+    "is-buffer": {
+      "version": "1.1.6",
+      "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
+      "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
+    },
     "is-callable": {
       "version": "1.2.4",
       "dev": true
@@ -6029,6 +6280,16 @@
       "version": "1.3.6",
       "dev": true
     },
+    "md5": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
+      "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
+      "requires": {
+        "charenc": "0.0.2",
+        "crypt": "0.0.2",
+        "is-buffer": "~1.1.6"
+      }
+    },
     "memorystream": {
       "version": "0.3.1",
       "dev": true
@@ -6047,11 +6308,13 @@
     },
     "mime-db": {
       "version": "1.52.0",
-      "dev": true
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
     },
     "mime-types": {
       "version": "2.1.35",
-      "dev": true,
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
       "requires": {
         "mime-db": "1.52.0"
       }
@@ -6110,6 +6373,11 @@
     "node-cron": {
       "version": "3.0.1"
     },
+    "node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
+    },
     "node-fetch": {
       "version": "2.6.7",
       "requires": {
@@ -6329,6 +6597,32 @@
         }
       }
     },
+    "openai": {
+      "version": "4.17.3",
+      "resolved": "https://registry.npmjs.org/openai/-/openai-4.17.3.tgz",
+      "integrity": "sha512-Gx9wzl9HWX5pjagkgXVu6U2BTFEPkQFdkppNnAX2n2Rpjtn2zt152wXh7NnZ5eJuVxUGYzRe66JmayAEGjzqAg==",
+      "requires": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "digest-fetch": "^1.3.0",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7",
+        "web-streams-polyfill": "^3.2.1"
+      },
+      "dependencies": {
+        "@types/node": {
+          "version": "18.18.9",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.9.tgz",
+          "integrity": "sha512-0f5klcuImLnG4Qreu9hPj/rEfFq6YRc5n2mAjSsH+ec/mJL+3voBH0+8T7o8RpFjH7ovc+TRsL/c7OYIQsPTfQ==",
+          "requires": {
+            "undici-types": "~5.26.4"
+          }
+        }
+      }
+    },
     "optionator": {
       "version": "0.9.1",
       "dev": true,
@@ -6992,6 +7286,11 @@
         "busboy": "^1.6.0"
       }
     },
+    "undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
+    },
     "uri-js": {
       "version": "4.4.1",
       "dev": true,
@@ -7082,6 +7381,11 @@
         "why-is-node-running": "^2.2.2"
       }
     },
+    "web-streams-polyfill": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz",
+      "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q=="
+    },
     "webidl-conversions": {
       "version": "3.0.1"
     },
diff --git a/package.json b/package.json
index 2983eca4..50a04e03 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
     "node-cron": "^3.0.0",
     "node-fetch": "^2.6.7",
     "open-graph-scraper": "^4.11.0",
+    "openai": "^4.17.3",
     "pretty-bytes": "^5.6.0",
     "query-string": "^6.2.0",
     "uuid": "^8.3.2"
diff --git a/src/constants/channels.ts b/src/constants/channels.ts
index 218c4fea..27078529 100644
--- a/src/constants/channels.ts
+++ b/src/constants/channels.ts
@@ -9,6 +9,7 @@ const LOCAL_CHANNELS: Record<keyof typeof PRODUCTION_CHANNELS, string> = {
   gaming: "926931785219207301",
   thanks: "926931785219207301",
   jobBoard: "925847361996095509",
+  resumeReview: "1166075559172907008",
   jobsLog: "925847644318879754",
   events: "950790520811184150",
   iBuiltThis: "950790520811184150",
@@ -27,6 +28,7 @@ const PRODUCTION_CHANNELS = {
   gaming: "509219336175747082",
   thanks: "798567961468076072",
   jobBoard: "103882387330457600",
+  resumeReview: "955507127877791795",
   jobsLog: "989201828572954694",
   events: "127442949435817984",
   iBuiltThis: "312761588778139658",
diff --git a/src/features/resume.ts b/src/features/resume.ts
new file mode 100644
index 00000000..92919387
--- /dev/null
+++ b/src/features/resume.ts
@@ -0,0 +1,179 @@
+import { CommandInteraction, SlashCommandBuilder } from "discord.js";
+import OpenAI from "openai";
+import { CHANNELS } from "../constants/channels";
+import { openAiKey } from "../helpers/env";
+import { sleep } from "../helpers/misc";
+import { logger } from "./log";
+
+// export const resumeResources = () => {};
+
+const openai = new OpenAI({
+  apiKey: openAiKey,
+});
+const ASSISTANT_ID = "asst_cC1ghvaaMFFTs3C06ycXqjeH";
+
+// one-time setup tasks for the assistant, so the functionality is all local
+const configure = async () => {
+  await openai.beta.assistants.update(ASSISTANT_ID, {
+    instructions: `
+You are a hiring manager reading resumes of engineers and providing feedback. You are part of Reactiflux, the Discord for React professionals, and were created by vcarl. You expect to be provided with a resume as a pdf. 
+
+Your response MUST be fewer than 1800 characters long.
+Be tactful and kind, but honest and forthright. Be terse, but not rude.
+Do not be overly complimentary, your role is to provide actionable feedback for improvements.
+Structure your response as a punchlist of feedback, not prose. Every item should be highly personal, do not make general recommendations like grammar and formatting, unless you see specific problems.
+
+Do your best to infer from their message and resume how the person views themselves professionally and what kind of work arrangement they're seeking (e.g., full-time, contract, freelance, etc). ALWAYS start your response by describing their goals and level of experience in 1 sentence (less than 30 words).
+
+Consult your knowledge for tips on resume formatting and writing.
+
+If their stated experience doesn't match what you would guess, describe why you made that inferrence.
+`,
+  });
+};
+configure();
+
+export const reviewResume = {
+  command: new SlashCommandBuilder()
+    .setName("review-resume")
+    .setDescription(
+      "BETA — AI may not be accurate. Use will upload a PDF resume to OpenAI systems temporarily.",
+    ),
+
+  handler: async (interaction: CommandInteraction) => {
+    // look at current thread
+    // if not started by thread author, abort
+    // if no PDF or direct file found, abort
+    if (!interaction.inGuild() || !interaction.channel) {
+      return await interaction.reply({
+        ephemeral: true,
+        content: "This must be performed in a guild!",
+      });
+    }
+    if (!interaction.channel.isThread()) {
+      return await interaction.reply({
+        ephemeral: true,
+        content: "Please use this in reply to a thread!",
+      });
+    }
+    const { channel, user } = interaction;
+    if (channel.parentId !== CHANNELS.resumeReview) {
+      return await interaction.reply({
+        ephemeral: true,
+        content: `This can only be executed in <#${CHANNELS.resumeReview}>!`,
+      });
+    }
+
+    const firstMessage = await channel.fetchStarterMessage();
+    if (!firstMessage) {
+      return await interaction.reply({
+        ephemeral: true,
+        content: "Couldn't fetch first message, please try again.",
+      });
+    }
+
+    if (firstMessage.author.id !== user.id) {
+      return await interaction.reply({
+        ephemeral: true,
+        content: "You may only review your own resume.",
+      });
+    }
+
+    const deferred = await interaction.deferReply({ ephemeral: true });
+    deferred.edit("Looking for a resume…");
+    const messages = await channel.messages.fetch();
+    // grab the first available PDF
+    const attachedPdfs = messages.flatMap((m) =>
+      m.attachments.filter((a) => a.contentType === "application/pdf"),
+    );
+    const resume = attachedPdfs.first();
+
+    if (!resume) {
+      return await deferred.edit({
+        content: "No PDFs found, please upload your resume and try again.",
+      });
+    }
+
+    // defer: notify that data will be sent, request permissions
+
+    // upload file to GPT
+    deferred.edit("Found a resume! Uploading…");
+    const response = await fetch(resume.url);
+    const file = await openai.files.create({
+      file: response,
+      purpose: "assistants",
+    });
+    if (!response.ok || file.status === "error") {
+      return await deferred.edit({
+        content: "Failed to upload resume, sorry! Please try again later.",
+      });
+    }
+
+    try {
+      deferred.edit("Uploaded! Reviewing…");
+      const [assistant, thread] = await Promise.all([
+        openai.beta.assistants.retrieve(ASSISTANT_ID),
+        openai.beta.threads.create({
+          messages: [
+            {
+              role: "user",
+              content:
+                "This user has requested help with their resume. Here is their message and resume:",
+            },
+            {
+              role: "user",
+              content: firstMessage.content,
+              file_ids: [file.id],
+            },
+          ],
+        }),
+      ]);
+
+      let run = await openai.beta.threads.runs.create(
+        thread.id,
+        { assistant_id: assistant.id },
+        // { stream: true },
+      );
+
+      // TODO: stream responses. OpenAI hasn't released streaming responses for
+      // assistant runs as of 2023-11
+      // let content = "";
+      // for await (const chunk of stream) {
+      //   content += chunk.choices[0]?.delta?.content;
+      //   sleep(1.5);
+      // }
+      while (run.status === "queued" || run.status === "in_progress") {
+        await sleep(0.5);
+        run = await openai.beta.threads.runs.retrieve(thread.id, run.id);
+        console.log(run.started_at, run.status);
+      }
+      console.log("run finished:", run.status, JSON.stringify(run, null, 2));
+
+      const messages = await openai.beta.threads.messages.list(thread.id);
+      console.log(JSON.stringify(messages.data, null, 2));
+      const content: string[] = messages.data
+        .filter((d) => d.role === "assistant")
+        .flatMap((d) =>
+          d.content.map((c) => (c.type === "text" ? c.text.value : "\n\n")),
+        );
+
+      console.log({ content });
+      const trimmed =
+        content.at(0)?.slice(0, 2000) ?? "Oops! Something went wrong.";
+      logger.log("[RESUME]", `Feedback given:`);
+      logger.log("[RESUME]", trimmed);
+      deferred.edit({
+        content: trimmed,
+      });
+    } catch (e) {
+      // recover
+      console.log(e);
+    }
+    // Ensure files are cleaned up
+    await openai.files.del(file.id);
+
+    // defer: offer fixed interaction buttons to send more prompts
+
+    return;
+  },
+};
diff --git a/src/helpers/env.ts b/src/helpers/env.ts
index 2e424c53..339fe4be 100644
--- a/src/helpers/env.ts
+++ b/src/helpers/env.ts
@@ -23,5 +23,6 @@ export const guildId = getEnv("GUILD_ID");
 export const discordToken = getEnv("DISCORD_HASH");
 export const gitHubToken = getEnv("GITHUB_TOKEN", true);
 export const amplitudeKey = getEnv("AMPLITUDE_KEY", true);
+export const openAiKey = getEnv("OPENAI_KEY", true);
 
 if (!ok) throw new Error("Environment misconfigured");
diff --git a/src/index.ts b/src/index.ts
index 35517d56..f23fac46 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -12,6 +12,7 @@ import discord, {
 import { logger, channelLog } from "./features/log";
 // import codeblock from './features/codeblock';
 import jobsMod, { resetJobCacheCommand } from "./features/jobs-moderation";
+import { reviewResume } from "./features/resume";
 import autoban from "./features/autoban";
 import commands from "./features/commands";
 import setupStats from "./features/stats";
@@ -42,6 +43,7 @@ export const bot = new discord.Client({
 });
 
 registerCommand(resetJobCacheCommand);
+registerCommand(reviewResume);
 
 logger.log("INI", "Bootstrap starting…");
 bot