From 4822221a30057128afea3d34391fae4a11d89b98 Mon Sep 17 00:00:00 2001 From: Steve Freeman Date: Sun, 22 Mar 2026 10:49:17 -0400 Subject: [PATCH 1/4] Refactored jobs to use bash instead of python for portability --- README.md | 8 + packages/jobs/fun-fact-job/Pipfile | 15 - packages/jobs/fun-fact-job/Pipfile.lock | 128 ------- packages/jobs/fun-fact-job/fun-fact-job.py | 313 ----------------- packages/jobs/fun-fact-job/script.sh | 384 ++++++++++++++++++++- packages/jobs/health-job/Pipfile | 13 - packages/jobs/health-job/Pipfile.lock | 128 ------- packages/jobs/health-job/job.py | 50 --- packages/jobs/health-job/script.sh | 98 +++++- packages/jobs/pricing-job/Pipfile | 13 - packages/jobs/pricing-job/Pipfile.lock | 88 ----- packages/jobs/pricing-job/pricing-job.py | 66 ---- packages/jobs/pricing-job/script.sh | 148 +++++++- 13 files changed, 626 insertions(+), 826 deletions(-) delete mode 100644 packages/jobs/fun-fact-job/Pipfile delete mode 100644 packages/jobs/fun-fact-job/Pipfile.lock delete mode 100644 packages/jobs/fun-fact-job/fun-fact-job.py delete mode 100644 packages/jobs/health-job/Pipfile delete mode 100644 packages/jobs/health-job/Pipfile.lock delete mode 100644 packages/jobs/health-job/job.py delete mode 100644 packages/jobs/pricing-job/Pipfile delete mode 100644 packages/jobs/pricing-job/Pipfile.lock delete mode 100644 packages/jobs/pricing-job/pricing-job.py diff --git a/README.md b/README.md index 7e5e4f38..6c56b6a7 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,14 @@ Each of the slash commands should have `Escape Channels, users and links sent to 3. `npm run start` (starts the backend server) +### Scheduled Jobs + +The scripts in `packages/jobs` are standalone bash jobs intended for cron-style execution. They no longer require Python or Pipenv. + +- `packages/jobs/health-job/script.sh` requires `bash` and `curl` +- `packages/jobs/fun-fact-job/script.sh` requires `bash`, `curl`, `jq`, and `mysql` +- `packages/jobs/pricing-job/script.sh` requires `bash`, `mysql`, `awk`, and `sort` + ### Available Scripts From the root directory, you can run: diff --git a/packages/jobs/fun-fact-job/Pipfile b/packages/jobs/fun-fact-job/Pipfile deleted file mode 100644 index f108f7a7..00000000 --- a/packages/jobs/fun-fact-job/Pipfile +++ /dev/null @@ -1,15 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -mysql-connector-python = "*" -mysql-connector = "*" -requests = "*" -slack-sdk = "*" - -[requires] -python_version = "3.8" diff --git a/packages/jobs/fun-fact-job/Pipfile.lock b/packages/jobs/fun-fact-job/Pipfile.lock deleted file mode 100644 index cea7ece3..00000000 --- a/packages/jobs/fun-fact-job/Pipfile.lock +++ /dev/null @@ -1,128 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "50fbca6981ca0cf95b1b4306cbe7a2071b4f6164f99ca4b37d4329df78e2cbd6" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", - "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" - ], - "version": "==2021.10.8" - }, - "charset-normalizer": { - "hashes": [ - "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721", - "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c" - ], - "markers": "python_version >= '3'", - "version": "==2.0.9" - }, - "idna": { - "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" - ], - "markers": "python_version >= '3'", - "version": "==3.3" - }, - "mysql-connector": { - "hashes": [ - "sha256:1733e6ce52a049243de3264f1fbc22a852cb35458c4ad739ba88189285efdf32" - ], - "index": "pypi", - "version": "==2.2.9" - }, - "mysql-connector-python": { - "hashes": [ - "sha256:049374e54441903022f1c277a7467e4e7cf72a8d89ca26e86d4fa26b7157346c", - "sha256:08e8bdb0b0cd247213764d115433972d0f5d103a00eb9cd0330294bdbb58cbca", - "sha256:2af9bf324649d056e8f1e0f212a046c8794a6b5ac4d7fa2be600db443d0b57ba", - "sha256:47e391ecae349e75ecffb513aec47ec3dbcfc8e2222ef9bd0b0494029eaa2a1b", - "sha256:667c712c0464527faee977d5db48f308e6b2d64396de0b5ba3fd459eda0653d0", - "sha256:6ec8ae4b51487f8b2d542b02e7026dddec92f29239daef2dbfcfbaa9fd5503f2", - "sha256:75fc7a089f1626ffbd22986090ca7cc3359c77ab9c4bde4bab1e30e15d4cbfd9", - "sha256:7a63dfded577f0a1800c863c4e9bcff7b583bcd369fc1eb4c2ec44b1f907e295", - "sha256:7fa3c4b571e5bab629dbce6013b36ff42efdfe47da6ff14cee25acd1a77649bb", - "sha256:8a3a8605c5380870a898b4a52c5b0d138e7cb998b192f10552373782d003886d", - "sha256:94abfd76c6ad36f1bcf96f49d76dd55b9e09767eea972669baba9fc385fd9a46", - "sha256:977ba6abdca01840afe27e461ec3a79550b50499782e5ff2933e513a52777870", - "sha256:ad393ddc1974da2b4e952156c3b1a8316f1cb14555b1ea83db6c3619232f8d89", - "sha256:b947650179a4778d7e13b354a3c7c3b5e13ec00d86727375a0cbba0b43ade82c", - "sha256:cf0c8e41edcd8a02f9ccbe925160ef12486111fcb2641d4551e3b2578afbe2c4", - "sha256:d15136f44fe36c135295719b2635686dbbe1b8043297b3420129368000cf2820", - "sha256:de6f3daa99242fcf559d87466ea95f37b6b9cd7257be516440abe6e925548ef9" - ], - "index": "pypi", - "version": "==8.0.27" - }, - "protobuf": { - "hashes": [ - "sha256:038daf4fa38a7e818dd61f51f22588d61755160a98db087a046f80d66b855942", - "sha256:28ccea56d4dc38d35cd70c43c2da2f40ac0be0a355ef882242e8586c6d66666f", - "sha256:36d90676d6f426718463fe382ec6274909337ca6319d375eebd2044e6c6ac560", - "sha256:3cd0458870ea7d1c58e948ac8078f6ba8a7ecc44a57e03032ed066c5bb318089", - "sha256:5935c8ce02e3d89c7900140a8a42b35bc037ec07a6aeb61cc108be8d3c9438a6", - "sha256:615b426a177780ce381ecd212edc1e0f70db8557ed72560b82096bd36b01bc04", - "sha256:62a8e4baa9cb9e064eb62d1002eca820857ab2138440cb4b3ea4243830f94ca7", - "sha256:655264ed0d0efe47a523e2255fc1106a22f6faab7cc46cfe99b5bae085c2a13e", - "sha256:6e8ea9173403219239cdfd8d946ed101f2ab6ecc025b0fda0c6c713c35c9981d", - "sha256:71b0250b0cfb738442d60cab68abc166de43411f2a4f791d31378590bfb71bd7", - "sha256:74f33edeb4f3b7ed13d567881da8e5a92a72b36495d57d696c2ea1ae0cfee80c", - "sha256:77d2fadcf369b3f22859ab25bd12bb8e98fb11e05d9ff9b7cd45b711c719c002", - "sha256:8b30a7de128c46b5ecb343917d9fa737612a6e8280f440874e5cc2ba0d79b8f6", - "sha256:8e51561d72efd5bd5c91490af1f13e32bcba8dab4643761eb7de3ce18e64a853", - "sha256:a529e7df52204565bcd33738a7a5f288f3d2d37d86caa5d78c458fa5fabbd54d", - "sha256:b691d996c6d0984947c4cf8b7ae2fe372d99b32821d0584f0b90277aa36982d3", - "sha256:d80f80eb175bf5f1169139c2e0c5ada98b1c098e2b3c3736667f28cbbea39fc8", - "sha256:d83e1ef8cb74009bebee3e61cc84b1c9cd04935b72bca0cbc83217d140424995", - "sha256:d8919368410110633717c406ab5c97e8df5ce93020cfcf3012834f28b1fab1ea", - "sha256:db3532d9f7a6ebbe2392041350437953b6d7a792de10e629c1e4f5a6b1fe1ac6", - "sha256:e7b24c11df36ee8e0c085e5b0dc560289e4b58804746fb487287dda51410f1e2", - "sha256:e7e8d2c20921f8da0dea277dfefc6abac05903ceac8e72839b2da519db69206b", - "sha256:e813b1c9006b6399308e917ac5d298f345d95bb31f46f02b60cd92970a9afa17", - "sha256:fd390367fc211cc0ffcf3a9e149dfeca78fecc62adb911371db0cec5c8b7472d" - ], - "markers": "python_version >= '3.5'", - "version": "==3.19.1" - }, - "requests": { - "hashes": [ - "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", - "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" - ], - "index": "pypi", - "version": "==2.26.0" - }, - "slack-sdk": { - "hashes": [ - "sha256:a384d91c10229f94a9b2cae2ec5af2a683a3d5aee1287c01238630ab42747287", - "sha256:f779ff3dc266491b02ad056d28038ec5d708b2a438a3a8f8794fb1121d8274e2" - ], - "index": "pypi", - "version": "==3.12.0" - }, - "urllib3": { - "hashes": [ - "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", - "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.7" - } - }, - "develop": {} -} diff --git a/packages/jobs/fun-fact-job/fun-fact-job.py b/packages/jobs/fun-fact-job/fun-fact-job.py deleted file mode 100644 index 80779be0..00000000 --- a/packages/jobs/fun-fact-job/fun-fact-job.py +++ /dev/null @@ -1,313 +0,0 @@ -import datetime -import mysql.connector -import os -import requests -import random -import ssl -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from urllib3 import Retry - -urls = [ - { "url": "https://uselessfacts.jsph.pl/random.json?language=en", "fieldName": "text" }, - { "url": "https://api.api-ninjas.com/v1/facts?limit=1", "fieldName": "fact", "headers": { "X-Api-Key": "{ninjaApiKey}".format(ninjaApiKey=os.environ["API_NINJA_KEY"])}} - ] - -quotes = [ - { "url": "https://quotes.rest/qod.json?category=inspire" } -] -ssl._create_default_https_context = ssl._create_unverified_context - -session = requests.session() -retry = Retry( - total=5, - backoff_factor=10 -) - -adapter = requests.adapters.HTTPAdapter(max_retries=retry) -session.mount('http://', adapter) -session.mount('https://', adapter) - -def getFacts(ctx): - facts = [] - - while(len(facts) < 5): - fact = getFact() - if isNewFact(fact["fact"], fact["source"], ctx): - addIdToDb(fact["fact"], fact["source"], ctx) - facts.append(fact) - - return facts - -def getQuote(): - url = random.choice(quotes) - quote = session.get(url["url"]) - if (quote.ok): - asJson = quote.json() - return { - "text": "{quote} - {author}".format(quote=asJson["contents"]["quotes"][0]["quote"], author=asJson["contents"]["quotes"][0]["author"]), - "image_url": "https://theysaidso.com/quote/image/{image_id}".format(image_id=asJson["contents"]["quotes"][0]["id"]) } - else: - return { - "error": "Issue with quote API - non 200 status code" - } - -def getOnThisDay(): - date = datetime.datetime.now() - day = date.day - month = date.month - if (day <= 9): - day = "0"+str(day) - if (month <= 9): - month = "0"+str(month) - - url="https://en.wikipedia.org/api/rest_v1/feed/onthisday/all/{month}/{day}".format(month=month, day=day) - onThisDay = session.get(url) - if (onThisDay): - onThisDayJson = onThisDay.json() - otd = onThisDayJson["selected"][0] - firstPage = otd["pages"][0] - print(firstPage) - hasThumbnail = firstPage.get("thumbnail") != None - return { "text": otd["text"], "url":firstPage["content_urls"]["desktop"]["page"], "image": firstPage["thumbnail"]["source"] if hasThumbnail else None, "title": firstPage["title"]} - else: - raise Exception("Unable to retrieve Wikipedia On This Day") - -def getJoke(ctx): - url = "https://v2.jokeapi.dev/joke/Miscellaneous,Pun,Spooky?blacklistFlags=racist,sexist" - joke = session.get(url) - - if(joke): - jokeJson = joke.json() - print(jokeJson) - if (isNewJoke(jokeJson["id"], ctx)): - addJokeIdToDb(jokeJson["id"],ctx) - if (jokeJson["type"] == "single"): - return jokeJson["joke"] - elif(jokeJson["type"] == "twopart"): - return "{setup} \n\n {delivery}".format(setup=jokeJson["setup"], delivery=jokeJson["delivery"]) - else: - getJoke(ctx) - else: - raise Exception("Unable to retrieve Joke of the Day") - -def getFact(): - url = random.choice(urls) - if ("headers" in url): - fact = session.get(url["url"], headers=url["headers"]) - else: - fact = session.get(url["url"]) - - if (fact): - asJson = fact.json() - if (isinstance(asJson, list)): - return { "fact": asJson[0][url["fieldName"]], "source": url["url"]} - else: - return { "fact": asJson[url["fieldName"]], "source": url["url"] } - else: - raise Exception("Unable to retrieve fact") - -def isNewFact(fact, source, ctx): - mycursor = ctx.cursor(dictionary=True, buffered=True) - mycursor.execute("SELECT fact FROM fact WHERE fact=%s AND source=%s;", (fact, source)) - dbFacts = mycursor.fetchall() - return len(dbFacts) == 0 - -def addIdToDb(fact, source, ctx): - mycursor = ctx.cursor(dictionary=True, buffered=True) - mycursor.execute("INSERT INTO fact (fact, source) VALUES (%s, %s);", (fact, source)) - ctx.commit() - -def isNewJoke(id, ctx): - mycursor = ctx.cursor(dictionary=True, buffered=True) - mycursor.execute("SELECT id FROM joke WHERE id=%s;", (str(id),)) - jokes = mycursor.fetchall() - return len(jokes) == 0 - -def addJokeIdToDb(id, ctx): - mycursor = ctx.cursor(dictionary=True, buffered=True) - mycursor.execute("INSERT INTO joke (id) VALUES (%s);", (str(id),)) - ctx.commit() - -def sendSlackMessage(facts, joke, quote, onThisDay): - blocks = createBlocks(quote, facts, onThisDay, joke) - slack_token = os.environ["MUZZLE_BOT_TOKEN"] - client = WebClient(token=slack_token) - - try: - client.api_call( - api_method='chat.postMessage', - json={'channel': '#general','blocks': blocks} - ) - - except SlackApiError as e: - # You will get a SlackApiError if "ok" is False - print(e) - assert e.response["error"] - -def createBlocks(quote, facts, otd, joke): - blocks = [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "SimpleTech's SimpleFacts :tm:", - "emoji": True - } - }] - if (quote and 'error' not in quote): - blocks.append({ - "type": "divider" - }) - - blocks.append({ - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Inspirational Quote of the Day* \n" - } - ] - }) - blocks.append({ - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{quote}".format(quote=quote["text"]) - } - }) - - blocks.append({ - "type": "divider" - }) - - blocks.append( - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Daily Joke:*" - } - ] - }) - - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{joke}".format(joke=joke) - } - }) - - blocks.append({ - "type": "divider" - }) - - blocks.append( - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*Daily Facts:*" - } - ] - }) - - factString = "" - for fact in facts: - factString = factString + "• {fact}\n".format(fact=fact["fact"]) - - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{fact}".format(fact=factString) - } - }) - - blocks.append({ - "type": "divider" - }) - - - blocks.append( - { - "type": "section", - "fields": [ - { - "type": "mrkdwn", - "text": "*On This Day:*" - } - ] - }) - - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "{text} \n\n <{url}|Learn More>".format(text=otd["text"], url=otd["url"]) - } - } - ) - - if (otd["image"]): - blocks.append({ - "accessory": { - "type": "image", - "image_url": "{url}".format(url=otd["image"]), - "alt_text": "{title}".format(title=otd["title"]) - } - }) - - blocks.append({ - "type": "divider" - }) - - blocks.append( - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "Disclaimer: SimpleTech's SimpleFacts :tm: offer no guarantee to the validity of the facts provided." - } - ] - } - ) - - - return blocks - -def main(): - try: - cnx = mysql.connector.connect( - host="localhost", - user=os.getenv('TYPEORM_USERNAME'), - password=os.getenv('TYPEORM_PASSWORD'), - database='fun_fact', - auth_plugin='mysql_native_password' - ) - except mysql.connector.Error as err: - if err.errno == mysql.connector.errorcode.ER_ACCESS_DENIED_ERROR: - raise Exception("Something is wrong with your user name or password") - elif err.errno == mysql.connector.errorcode.ER_BAD_DB_ERROR: - raise Exception("Database does not exist") - else: - raise Exception(err) - - - - facts = getFacts(cnx) - joke = getJoke(cnx) - quote = getQuote() - onThisDay = getOnThisDay() - - sendSlackMessage(facts, joke, quote, onThisDay) - - -main() \ No newline at end of file diff --git a/packages/jobs/fun-fact-job/script.sh b/packages/jobs/fun-fact-job/script.sh index 90ae0443..ed483e63 100755 --- a/packages/jobs/fun-fact-job/script.sh +++ b/packages/jobs/fun-fact-job/script.sh @@ -1,5 +1,381 @@ -. /home/muzzle.lol/.bash_profile -PATH=/usr/local/bin:$PATH -cd /home/muzzle.lol/mocker/fun-fact-job -pipenv run python ./fun-fact-job.py +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -f /home/muzzle.lol/.bash_profile ]]; then + # Cron runs with a minimal environment, so load the deployed shell profile when present. + # shellcheck disable=SC1091 + . /home/muzzle.lol/.bash_profile +fi + +PATH=/usr/local/bin:/usr/bin:/bin:${PATH:-} + +FACT_TARGET_COUNT="${FACT_TARGET_COUNT:-5}" +MAX_FACT_ATTEMPTS="${MAX_FACT_ATTEMPTS:-50}" +MAX_JOKE_ATTEMPTS="${MAX_JOKE_ATTEMPTS:-20}" +MYSQL_HOST="${TYPEORM_HOST:-localhost}" +MYSQL_USER="${TYPEORM_USERNAME:-}" +MYSQL_PASSWORD="${TYPEORM_PASSWORD:-}" +MYSQL_DATABASE="${FUN_FACT_DATABASE:-fun_fact}" +SLACK_CHANNEL="${SLACK_CHANNEL:-#general}" +SLACK_TEXT_FALLBACK="${SLACK_TEXT_FALLBACK:-SimpleTech SimpleFacts}" + +USELESS_FACTS_URL="https://uselessfacts.jsph.pl/random.json?language=en" +API_NINJAS_URL="https://api.api-ninjas.com/v1/facts?limit=1" +QUOTE_URL="https://quotes.rest/qod.json?category=inspire" +JOKE_URL="https://v2.jokeapi.dev/joke/Miscellaneous,Pun,Spooky?blacklistFlags=racist,sexist" + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Missing required command: ${command_name}" >&2 + exit 1 + fi +} + +require_env() { + local env_name="$1" + + if [[ -z "${!env_name:-}" ]]; then + echo "Missing required environment variable: ${env_name}" >&2 + exit 1 + fi +} + +sql_escape() { + printf '%s' "$1" | sed "s/'/''/g" +} + +mysql_query() { + local query="$1" + MYSQL_PWD="${MYSQL_PASSWORD}" mysql \ + --batch \ + --raw \ + --skip-column-names \ + -h "${MYSQL_HOST}" \ + -u "${MYSQL_USER}" \ + "${MYSQL_DATABASE}" \ + -e "${query}" +} + +curl_json() { + local url="$1" + shift || true + + curl \ + --silent \ + --show-error \ + --fail \ + --retry 5 \ + --retry-delay 2 \ + --retry-all-errors \ + "$@" \ + "${url}" +} + +is_new_fact() { + local fact="$1" + local source="$2" + local count + + count=$(mysql_query "SELECT COUNT(*) FROM fact WHERE fact='$(sql_escape "${fact}")' AND source='$(sql_escape "${source}")';") + [[ "${count}" == "0" ]] +} + +add_fact() { + local fact="$1" + local source="$2" + + mysql_query "INSERT INTO fact (fact, source) VALUES ('$(sql_escape "${fact}")', '$(sql_escape "${source}")');" >/dev/null +} + +is_new_joke() { + local joke_id="$1" + local count + + count=$(mysql_query "SELECT COUNT(*) FROM joke WHERE id='$(sql_escape "${joke_id}")';") + [[ "${count}" == "0" ]] +} + +add_joke_id() { + local joke_id="$1" + mysql_query "INSERT INTO joke (id) VALUES ('$(sql_escape "${joke_id}")');" >/dev/null +} + +fetch_fact() { + local response + + if (( RANDOM % 2 == 0 )); then + response=$(curl_json "${USELESS_FACTS_URL}") + jq -cn \ + --arg fact "$(jq -r '.text' <<<"${response}")" \ + --arg source "${USELESS_FACTS_URL}" \ + '{fact: $fact, source: $source}' + else + response=$(curl_json "${API_NINJAS_URL}" --header "X-Api-Key: ${API_NINJA_KEY}") + jq -cn \ + --arg fact "$(jq -r '.[0].fact' <<<"${response}")" \ + --arg source "${API_NINJAS_URL}" \ + '{fact: $fact, source: $source}' + fi +} + +collect_facts() { + local -a facts=() + local attempts=0 + local fact_json + local fact + local source + + while (( ${#facts[@]} < FACT_TARGET_COUNT )); do + (( attempts++ )) + if (( attempts > MAX_FACT_ATTEMPTS )); then + echo "Unable to collect ${FACT_TARGET_COUNT} unique facts after ${MAX_FACT_ATTEMPTS} attempts" >&2 + return 1 + fi + + fact_json=$(fetch_fact) + fact=$(jq -r '.fact' <<<"${fact_json}") + source=$(jq -r '.source' <<<"${fact_json}") + + if is_new_fact "${fact}" "${source}"; then + add_fact "${fact}" "${source}" + facts+=("${fact_json}") + echo "Collected fact ${#facts[@]}/${FACT_TARGET_COUNT}" + fi + done + + printf '%s\n' "${facts[@]}" +} + +fetch_quote() { + local response + + if ! response=$(curl_json "${QUOTE_URL}" 2>/dev/null); then + jq -cn '{error: "Issue with quote API - non 200 status code"}' + return 0 + fi + + jq -c '{text: (.contents.quotes[0].quote + " - " + .contents.quotes[0].author), image_url: ("https://theysaidso.com/quote/image/" + .contents.quotes[0].id)}' <<<"${response}" +} + +fetch_on_this_day() { + local month + local day + local response + + month=$(date +%m) + day=$(date +%d) + response=$(curl_json "https://en.wikipedia.org/api/rest_v1/feed/onthisday/all/${month}/${day}") + + jq -c '{text: .selected[0].text, url: .selected[0].pages[0].content_urls.desktop.page, image: (.selected[0].pages[0].thumbnail.source // null), title: .selected[0].pages[0].title}' <<<"${response}" +} + +fetch_joke() { + local attempt=0 + local response + local joke_id + + while (( attempt < MAX_JOKE_ATTEMPTS )); do + (( attempt++ )) + response=$(curl_json "${JOKE_URL}") + joke_id=$(jq -r '.id' <<<"${response}") + + if is_new_joke "${joke_id}"; then + add_joke_id "${joke_id}" + if [[ "$(jq -r '.type' <<<"${response}")" == 'single' ]]; then + jq -r '.joke' <<<"${response}" + else + jq -r '(.setup + " \n\n " + .delivery)' <<<"${response}" + fi + return 0 + fi + done + + echo "Unable to retrieve a unique joke after ${MAX_JOKE_ATTEMPTS} attempts" >&2 + return 1 +} + +build_blocks() { + local quote_json="$1" + local facts_json="$2" + local on_this_day_json="$3" + local joke_text="$4" + local facts_text + + facts_text=$(jq -r 'map("• " + .fact) | join("\n")' <<<"${facts_json}") + + jq -n \ + --arg joke "${joke_text}" \ + --arg facts_text "${facts_text}" \ + --argjson quote "${quote_json}" \ + --argjson otd "${on_this_day_json}" ' + [ + { + type: "header", + text: { + type: "plain_text", + text: "SimpleTech\u0027s SimpleFacts :tm:", + emoji: true + } + } + ] + + (if $quote.error? == null then [ + {type: "divider"}, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: "*Inspirational Quote of the Day* \n" + } + ] + }, + { + type: "section", + text: { + type: "mrkdwn", + text: $quote.text + } + } + ] else [] end) + + [ + {type: "divider"}, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: "*Daily Joke:*" + } + ] + }, + { + type: "section", + text: { + type: "mrkdwn", + text: $joke + } + }, + {type: "divider"}, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: "*Daily Facts:*" + } + ] + }, + { + type: "section", + text: { + type: "mrkdwn", + text: $facts_text + } + }, + {type: "divider"}, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: "*On This Day:*" + } + ] + }, + { + type: "section", + text: { + type: "mrkdwn", + text: ($otd.text + " \n\n <" + $otd.url + "|Learn More>") + } + } + ] + + (if $otd.image != null then [ + { + type: "image", + image_url: $otd.image, + alt_text: $otd.title + } + ] else [] end) + + [ + {type: "divider"}, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "Disclaimer: SimpleTech\u0027s SimpleFacts :tm: offer no guarantee to the validity of the facts provided." + } + ] + } + ] + ' +} + +send_slack_message() { + local blocks_json="$1" + local response_file + local response_code + local payload + + response_file=$(mktemp) + payload=$(jq -n \ + --arg channel "${SLACK_CHANNEL}" \ + --arg text "${SLACK_TEXT_FALLBACK}" \ + --argjson blocks "${blocks_json}" \ + '{channel: $channel, text: $text, blocks: $blocks}') + + response_code=$(curl \ + --silent \ + --show-error \ + --output "${response_file}" \ + --write-out '%{http_code}' \ + --request POST \ + --header "Authorization: Bearer ${MUZZLE_BOT_TOKEN}" \ + --header 'Content-Type: application/json; charset=utf-8' \ + --data "${payload}" \ + https://slack.com/api/chat.postMessage) + + if [[ "${response_code}" != '200' ]] || ! jq -e '.ok == true' "${response_file}" >/dev/null 2>&1; then + cat "${response_file}" >&2 + rm -f "${response_file}" + return 1 + fi + + rm -f "${response_file}" +} + +main() { + local -a facts_json=() + local facts_array_json + local joke_text + local quote_json + local on_this_day_json + local blocks_json + local fact_line + + require_command curl + require_command jq + require_command mysql + require_env TYPEORM_USERNAME + require_env TYPEORM_PASSWORD + require_env MUZZLE_BOT_TOKEN + require_env API_NINJA_KEY + + while IFS= read -r fact_line; do + facts_json+=("${fact_line}") + done < <(collect_facts) + facts_array_json=$(printf '%s\n' "${facts_json[@]}" | jq -s '.') + joke_text=$(fetch_joke) + quote_json=$(fetch_quote) + on_this_day_json=$(fetch_on_this_day) + blocks_json=$(build_blocks "${quote_json}" "${facts_array_json}" "${on_this_day_json}" "${joke_text}") + + send_slack_message "${blocks_json}" +} + +main "$@" diff --git a/packages/jobs/health-job/Pipfile b/packages/jobs/health-job/Pipfile deleted file mode 100644 index 828eb5f7..00000000 --- a/packages/jobs/health-job/Pipfile +++ /dev/null @@ -1,13 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -requests = "*" -slack-sdk = "*" - -[requires] -python_version = "3.8" diff --git a/packages/jobs/health-job/Pipfile.lock b/packages/jobs/health-job/Pipfile.lock deleted file mode 100644 index cea7ece3..00000000 --- a/packages/jobs/health-job/Pipfile.lock +++ /dev/null @@ -1,128 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "50fbca6981ca0cf95b1b4306cbe7a2071b4f6164f99ca4b37d4329df78e2cbd6" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "certifi": { - "hashes": [ - "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872", - "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569" - ], - "version": "==2021.10.8" - }, - "charset-normalizer": { - "hashes": [ - "sha256:1eecaa09422db5be9e29d7fc65664e6c33bd06f9ced7838578ba40d58bdf3721", - "sha256:b0b883e8e874edfdece9c28f314e3dd5badf067342e42fb162203335ae61aa2c" - ], - "markers": "python_version >= '3'", - "version": "==2.0.9" - }, - "idna": { - "hashes": [ - "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff", - "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d" - ], - "markers": "python_version >= '3'", - "version": "==3.3" - }, - "mysql-connector": { - "hashes": [ - "sha256:1733e6ce52a049243de3264f1fbc22a852cb35458c4ad739ba88189285efdf32" - ], - "index": "pypi", - "version": "==2.2.9" - }, - "mysql-connector-python": { - "hashes": [ - "sha256:049374e54441903022f1c277a7467e4e7cf72a8d89ca26e86d4fa26b7157346c", - "sha256:08e8bdb0b0cd247213764d115433972d0f5d103a00eb9cd0330294bdbb58cbca", - "sha256:2af9bf324649d056e8f1e0f212a046c8794a6b5ac4d7fa2be600db443d0b57ba", - "sha256:47e391ecae349e75ecffb513aec47ec3dbcfc8e2222ef9bd0b0494029eaa2a1b", - "sha256:667c712c0464527faee977d5db48f308e6b2d64396de0b5ba3fd459eda0653d0", - "sha256:6ec8ae4b51487f8b2d542b02e7026dddec92f29239daef2dbfcfbaa9fd5503f2", - "sha256:75fc7a089f1626ffbd22986090ca7cc3359c77ab9c4bde4bab1e30e15d4cbfd9", - "sha256:7a63dfded577f0a1800c863c4e9bcff7b583bcd369fc1eb4c2ec44b1f907e295", - "sha256:7fa3c4b571e5bab629dbce6013b36ff42efdfe47da6ff14cee25acd1a77649bb", - "sha256:8a3a8605c5380870a898b4a52c5b0d138e7cb998b192f10552373782d003886d", - "sha256:94abfd76c6ad36f1bcf96f49d76dd55b9e09767eea972669baba9fc385fd9a46", - "sha256:977ba6abdca01840afe27e461ec3a79550b50499782e5ff2933e513a52777870", - "sha256:ad393ddc1974da2b4e952156c3b1a8316f1cb14555b1ea83db6c3619232f8d89", - "sha256:b947650179a4778d7e13b354a3c7c3b5e13ec00d86727375a0cbba0b43ade82c", - "sha256:cf0c8e41edcd8a02f9ccbe925160ef12486111fcb2641d4551e3b2578afbe2c4", - "sha256:d15136f44fe36c135295719b2635686dbbe1b8043297b3420129368000cf2820", - "sha256:de6f3daa99242fcf559d87466ea95f37b6b9cd7257be516440abe6e925548ef9" - ], - "index": "pypi", - "version": "==8.0.27" - }, - "protobuf": { - "hashes": [ - "sha256:038daf4fa38a7e818dd61f51f22588d61755160a98db087a046f80d66b855942", - "sha256:28ccea56d4dc38d35cd70c43c2da2f40ac0be0a355ef882242e8586c6d66666f", - "sha256:36d90676d6f426718463fe382ec6274909337ca6319d375eebd2044e6c6ac560", - "sha256:3cd0458870ea7d1c58e948ac8078f6ba8a7ecc44a57e03032ed066c5bb318089", - "sha256:5935c8ce02e3d89c7900140a8a42b35bc037ec07a6aeb61cc108be8d3c9438a6", - "sha256:615b426a177780ce381ecd212edc1e0f70db8557ed72560b82096bd36b01bc04", - "sha256:62a8e4baa9cb9e064eb62d1002eca820857ab2138440cb4b3ea4243830f94ca7", - "sha256:655264ed0d0efe47a523e2255fc1106a22f6faab7cc46cfe99b5bae085c2a13e", - "sha256:6e8ea9173403219239cdfd8d946ed101f2ab6ecc025b0fda0c6c713c35c9981d", - "sha256:71b0250b0cfb738442d60cab68abc166de43411f2a4f791d31378590bfb71bd7", - "sha256:74f33edeb4f3b7ed13d567881da8e5a92a72b36495d57d696c2ea1ae0cfee80c", - "sha256:77d2fadcf369b3f22859ab25bd12bb8e98fb11e05d9ff9b7cd45b711c719c002", - "sha256:8b30a7de128c46b5ecb343917d9fa737612a6e8280f440874e5cc2ba0d79b8f6", - "sha256:8e51561d72efd5bd5c91490af1f13e32bcba8dab4643761eb7de3ce18e64a853", - "sha256:a529e7df52204565bcd33738a7a5f288f3d2d37d86caa5d78c458fa5fabbd54d", - "sha256:b691d996c6d0984947c4cf8b7ae2fe372d99b32821d0584f0b90277aa36982d3", - "sha256:d80f80eb175bf5f1169139c2e0c5ada98b1c098e2b3c3736667f28cbbea39fc8", - "sha256:d83e1ef8cb74009bebee3e61cc84b1c9cd04935b72bca0cbc83217d140424995", - "sha256:d8919368410110633717c406ab5c97e8df5ce93020cfcf3012834f28b1fab1ea", - "sha256:db3532d9f7a6ebbe2392041350437953b6d7a792de10e629c1e4f5a6b1fe1ac6", - "sha256:e7b24c11df36ee8e0c085e5b0dc560289e4b58804746fb487287dda51410f1e2", - "sha256:e7e8d2c20921f8da0dea277dfefc6abac05903ceac8e72839b2da519db69206b", - "sha256:e813b1c9006b6399308e917ac5d298f345d95bb31f46f02b60cd92970a9afa17", - "sha256:fd390367fc211cc0ffcf3a9e149dfeca78fecc62adb911371db0cec5c8b7472d" - ], - "markers": "python_version >= '3.5'", - "version": "==3.19.1" - }, - "requests": { - "hashes": [ - "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24", - "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7" - ], - "index": "pypi", - "version": "==2.26.0" - }, - "slack-sdk": { - "hashes": [ - "sha256:a384d91c10229f94a9b2cae2ec5af2a683a3d5aee1287c01238630ab42747287", - "sha256:f779ff3dc266491b02ad056d28038ec5d708b2a438a3a8f8794fb1121d8274e2" - ], - "index": "pypi", - "version": "==3.12.0" - }, - "urllib3": { - "hashes": [ - "sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece", - "sha256:c4fdf4019605b6e5423637e01bc9fe4daef873709a7973e195ceba0a62bbc844" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.26.7" - } - }, - "develop": {} -} diff --git a/packages/jobs/health-job/job.py b/packages/jobs/health-job/job.py deleted file mode 100644 index 2d09b611..00000000 --- a/packages/jobs/health-job/job.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import requests -import ssl -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError -from urllib3.util import Retry - -ssl._create_default_https_context = ssl._create_unverified_context - -session = requests.session() - -retries = Retry(total=5, - backoff_factor=0.1, - status_forcelist=[ 500, 502, 503, 504 ]) - -adapter = requests.adapters.HTTPAdapter(max_retries=retries) -session.mount('http://', adapter) -session.mount('https://', adapter) - -def getHealth(): - try: - url = "http://127.0.0.1:3000/health" - health = session.get(url) - print(health) - if (health.ok == False): - sendSlackMessage() - except requests.exceptions.ConnectionError as e: - print(e) - sendSlackMessage() - -def sendSlackMessage(): - slack_token = os.environ["MUZZLE_BOT_TOKEN"] - client = WebClient(token=slack_token) - - try: - client.api_call( - api_method='chat.postMessage', - json={'channel': '#muzzlefeedback','text': ':this-is-fine: `Moonbeam is experiencing some technical difficulties at the moment.` :this-is-fine:'} - ) - - except SlackApiError as e: - # You will get a SlackApiError if "ok" is False - print(e) - assert e.response["error"] - -def main(): - getHealth() - - -main() \ No newline at end of file diff --git a/packages/jobs/health-job/script.sh b/packages/jobs/health-job/script.sh index f68942e6..da997832 100755 --- a/packages/jobs/health-job/script.sh +++ b/packages/jobs/health-job/script.sh @@ -1,5 +1,95 @@ -. /home/muzzle.lol/.bash_profile -PATH=/usr/local/bin:$PATH -cd /home/muzzle.lol/mocker/health-job -pipenv run python ./job.py +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -f /home/muzzle.lol/.bash_profile ]]; then + # Cron runs with a minimal environment, so load the deployed shell profile when present. + # shellcheck disable=SC1091 + . /home/muzzle.lol/.bash_profile +fi + +PATH=/usr/local/bin:/usr/bin:/bin:${PATH:-} + +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/health}" +SLACK_CHANNEL="${SLACK_CHANNEL:-#muzzlefeedback}" +SLACK_MESSAGE=':this-is-fine: `Moonbeam is experiencing some technical difficulties at the moment.` :this-is-fine:' +MAX_ATTEMPTS="${MAX_ATTEMPTS:-5}" +SLEEP_SECONDS="${SLEEP_SECONDS:-1}" +CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-5}" +MAX_TIME="${MAX_TIME:-15}" + +send_slack_message() { + if [[ -z "${MUZZLE_BOT_TOKEN:-}" ]]; then + echo "MUZZLE_BOT_TOKEN is not set; unable to send Slack alert" >&2 + return 1 + fi + + local response_code + local response_body + + response_body=$(mktemp) + + response_code=$(curl \ + --silent \ + --show-error \ + --output "${response_body}" \ + --write-out '%{http_code}' \ + --request POST \ + --header "Authorization: Bearer ${MUZZLE_BOT_TOKEN}" \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "channel=${SLACK_CHANNEL}" \ + --data-urlencode "text=${SLACK_MESSAGE}" \ + https://slack.com/api/chat.postMessage) + + if [[ "${response_code}" != "200" ]]; then + echo "Slack API request failed with HTTP ${response_code}" >&2 + rm -f "${response_body}" + return 1 + fi + + if ! grep -q '"ok":true' "${response_body}"; then + echo "Slack API request failed: $(cat "${response_body}")" >&2 + rm -f "${response_body}" + return 1 + fi + + rm -f "${response_body}" +} + +check_health() { + local attempt=1 + local response_code + + while (( attempt <= MAX_ATTEMPTS )); do + response_code=$(curl \ + --silent \ + --show-error \ + --output /dev/null \ + --write-out '%{http_code}' \ + --connect-timeout "${CONNECT_TIMEOUT}" \ + --max-time "${MAX_TIME}" \ + "${HEALTH_URL}" || true) + + echo "Health check attempt ${attempt}/${MAX_ATTEMPTS}: HTTP ${response_code:-curl-error}" + + if [[ "${response_code}" =~ ^2[0-9][0-9]$ ]]; then + return 0 + fi + + (( attempt++ )) + sleep "${SLEEP_SECONDS}" + done + + return 1 +} + +main() { + if check_health; then + exit 0 + fi + + send_slack_message +} + +main "$@" diff --git a/packages/jobs/pricing-job/Pipfile b/packages/jobs/pricing-job/Pipfile deleted file mode 100644 index ca5767d6..00000000 --- a/packages/jobs/pricing-job/Pipfile +++ /dev/null @@ -1,13 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[dev-packages] - -[packages] -mysql-connector-python = "*" -mysql-connector = "*" - -[requires] -python_version = "3.8" diff --git a/packages/jobs/pricing-job/Pipfile.lock b/packages/jobs/pricing-job/Pipfile.lock deleted file mode 100644 index a2d194a4..00000000 --- a/packages/jobs/pricing-job/Pipfile.lock +++ /dev/null @@ -1,88 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "fe6503de4589c18e35237370d7f6d72b468b32fe4114d0e41406d2cdc1915e37" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.8" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "mysql-connector": { - "hashes": [ - "sha256:1733e6ce52a049243de3264f1fbc22a852cb35458c4ad739ba88189285efdf32" - ], - "index": "pypi", - "version": "==2.2.9" - }, - "mysql-connector-python": { - "hashes": [ - "sha256:00403ae0099248cba6076eb5454c8012c33fc011164fcaccd68b78c9298e3574", - "sha256:07f6dea4b48b6afdbc952431a76c5e6fc1fa02d57e324670d5c4d73c54a981ff", - "sha256:0eecec5ab1a4ba03741bee5ec3cb02a8647470ba4a5c50a14c49425db2ec3590", - "sha256:14275f55706e225be0f0044f15fb9a9146c3015d81e12292dbfe8c830de4ee55", - "sha256:1ac799506b4fe5d44d31861c75299478125d855cbdc073de23a67bf3f468f265", - "sha256:2a467b290c2256277fbe4ddf1630a75f21d2a887b4c7bfe9c4afde86dae49848", - "sha256:322883e4850fa0064c9a60fcc11a8697cd691d9fa1b7e0a2c1b9eb673be1006a", - "sha256:689245f055b81cfd5e2f19726e2ca7226022f685c5ec828a37e6c66d5a000281", - "sha256:6a2f237ced3468a37abe240a787717271bf576413a9fb36ef6f049e39f3a5b29", - "sha256:6e037712775209f2951da4be5be02720f18b7943b8a6f9a2a810e168c001af38", - "sha256:7a2294bc4b49b15fb0381cf4718ebe002d4c01d15bfc3f7a37cc2e03260611a6", - "sha256:7eef083bc90469d97b2f9ee1ec9a06c8332590bda80c7a6023c97678a19bc0ea", - "sha256:83b8ca88bb99445cef7c0dca9c0e470c15fd72365a261dafa1da6f885bfb14fe", - "sha256:86cfa8f05856db41394590b6d4d34d11e98f3550e7ef2a7add27a1a18c590968", - "sha256:97950141f4d902d5ba8590c2be8da481654b07eca1da74c6b5d0b43849fd7e33", - "sha256:995661221a686676920f6e2b34dd7ac77992449ae860677dfab7e95bce87deec", - "sha256:a1cbd47a2ffa919f69df34e52d090ee97442ef9bb0e0ae56045bbf72f14be26e", - "sha256:a6502199133fcefeb04ac91638a07b326ea3e7d8b8216da11eb2e763836eeee8", - "sha256:cb8818260a5731b0617c1da6960dc947e06d408ddd7ff387e102403c9b7ed90e", - "sha256:e10975b4adfedd269b30cd5dc3a6bd683b4f53345b7e97e35b7a7281b9062547", - "sha256:e9e0054fb273301a369065b30077f51966630388ac21c370e941215b479772f3", - "sha256:ebd5df00417a1047a19e02643bbe1c7735a870384e1f21d96c97d98045925890", - "sha256:ecfa762cff5d3f7373494f74574df62fdd30f051e18bfe45bfa3d9713de5c759" - ], - "index": "pypi", - "version": "==8.0.21" - }, - "protobuf": { - "hashes": [ - "sha256:0bba42f439bf45c0f600c3c5993666fcb88e8441d011fad80a11df6f324eef33", - "sha256:1e834076dfef9e585815757a2c7e4560c7ccc5962b9d09f831214c693a91b463", - "sha256:339c3a003e3c797bc84499fa32e0aac83c768e67b3de4a5d7a5a9aa3b0da634c", - "sha256:361acd76f0ad38c6e38f14d08775514fbd241316cce08deb2ce914c7dfa1184a", - "sha256:3dee442884a18c16d023e52e32dd34a8930a889e511af493f6dc7d4d9bf12e4f", - "sha256:4d1174c9ed303070ad59553f435846a2f877598f59f9afc1b89757bdf846f2a7", - "sha256:5db9d3e12b6ede5e601b8d8684a7f9d90581882925c96acf8495957b4f1b204b", - "sha256:6a82e0c8bb2bf58f606040cc5814e07715b2094caeba281e2e7d0b0e2e397db5", - "sha256:8c35bcbed1c0d29b127c886790e9d37e845ffc2725cc1db4bd06d70f4e8359f4", - "sha256:91c2d897da84c62816e2f473ece60ebfeab024a16c1751aaf31100127ccd93ec", - "sha256:9c2e63c1743cba12737169c447374fab3dfeb18111a460a8c1a000e35836b18c", - "sha256:9edfdc679a3669988ec55a989ff62449f670dfa7018df6ad7f04e8dbacb10630", - "sha256:c0c5ab9c4b1eac0a9b838f1e46038c3175a95b0f2d944385884af72876bd6bc7", - "sha256:c8abd7605185836f6f11f97b21200f8a864f9cb078a193fe3c9e235711d3ff1e", - "sha256:d69697acac76d9f250ab745b46c725edf3e98ac24763990b24d58c16c642947a", - "sha256:df3932e1834a64b46ebc262e951cd82c3cf0fa936a154f0a42231140d8237060", - "sha256:e7662437ca1e0c51b93cadb988f9b353fa6b8013c0385d63a70c8a77d84da5f9", - "sha256:f68eb9d03c7d84bd01c790948320b768de8559761897763731294e3bc316decb" - ], - "version": "==3.13.0" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.15.0" - } - }, - "develop": {} -} diff --git a/packages/jobs/pricing-job/pricing-job.py b/packages/jobs/pricing-job/pricing-job.py deleted file mode 100644 index a824a069..00000000 --- a/packages/jobs/pricing-job/pricing-job.py +++ /dev/null @@ -1,66 +0,0 @@ -import mysql.connector -import os -import time -import math - -print("Beginning pricing job...") -start = time.time() -try: - print("Connecting to mysql DB...") - cnx = mydb = mysql.connector.connect( - host="localhost", - user=os.getenv('TYPEORM_USERNAME'), - password=os.getenv('TYPEORM_PASSWORD'), - database=os.getenv('TYPEORM_DATABASE'), - auth_plugin='mysql_native_password' - ) -except mysql.connector.Error as err: - if err.errno == errorcode.ER_ACCESS_DENIED_ERROR: - print("Something is wrong with your user name or password") - elif err.errno == errorcode.ER_BAD_DB_ERROR: - print("Database does not exist") - else: - print(err) - -print("Connected!") -mycursor = cnx.cursor(dictionary=True, buffered=True) - -print('Retrieving distinct teams...') -mycursor.execute("SELECT DISTINCT(teamId) FROM slack_user;") - -teams = mycursor.fetchall() -print('Teams retrieved!') -print('Retrieving all items...') -mycursor.execute("SELECT id, pricePct from item;") -print('Items retrieved!') - -items = mycursor.fetchall() - -for team in teams: - # get total earned rep by team per user. - totalEarnedRepQuery= """SELECT SUM(value) as sum, affectedUser FROM reaction GROUP BY affectedUser ORDER BY sum DESC;""" - mycursor.execute(totalEarnedRepQuery) - totalEarnedRep = mycursor.fetchall() - #get total spent rep by team per user - totalSpentRepQuery = """SELECT SUM(price) as sum, user FROM purchase GROUP BY user ORDER BY sum DESC;""" - mycursor.execute(totalSpentRepQuery) - totalRepSpent = mycursor.fetchall() - repMap = {} - for totalRep in totalEarnedRep: - repMap[totalRep['affectedUser']] = totalRep['sum'] - for totalSpent in totalRepSpent: - repMap[totalSpent['user']] = repMap[totalSpent['user']] - totalSpent['sum'] - repMap = {key: val for key, val in sorted(repMap.items(), key = lambda ele: ele[1], reverse = True)} - medianIdx = math.floor((len(repMap) + 1 ) / 2) - repList = list(repMap.items()) - medianRep = repList[medianIdx][1] - for item in items: - item_id = item['id'] - team_id= team['teamId'] - price = float(medianRep) * item['pricePct'] - item_query="INSERT INTO price(itemId, teamId, price, itemIdId) VALUES({item_id}, '{team_id}', {price}, {item_id});".format(item_id=item_id, team_id=team_id, price=price) - mycursor.execute(item_query) - cnx.commit() - print("Completed update for {team}".format(team=team['teamId'])) - -print("Completed job in {time} seconds!".format(time=time.time() - start)) diff --git a/packages/jobs/pricing-job/script.sh b/packages/jobs/pricing-job/script.sh index eb2792f3..b01fa404 100755 --- a/packages/jobs/pricing-job/script.sh +++ b/packages/jobs/pricing-job/script.sh @@ -1,4 +1,144 @@ -. /home/muzzle.lol/.bash_profile -PATH=/usr/local/bin:$PATH -cd /home/muzzle.lol/mocker/pricing-job -pipenv run python ./pricing-job.py +#!/usr/bin/env bash + +set -euo pipefail + +if [[ -f /home/muzzle.lol/.bash_profile ]]; then + # Cron runs with a minimal environment, so load the deployed shell profile when present. + # shellcheck disable=SC1091 + . /home/muzzle.lol/.bash_profile +fi + +PATH=/usr/local/bin:/usr/bin:/bin:${PATH:-} + +MYSQL_HOST="${TYPEORM_HOST:-localhost}" +MYSQL_USER="${TYPEORM_USERNAME:-}" +MYSQL_PASSWORD="${TYPEORM_PASSWORD:-}" +MYSQL_DATABASE="${TYPEORM_DATABASE:-}" + +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Missing required command: ${command_name}" >&2 + exit 1 + fi +} + +require_env() { + local env_name="$1" + + if [[ -z "${!env_name:-}" ]]; then + echo "Missing required environment variable: ${env_name}" >&2 + exit 1 + fi +} + +sql_escape() { + printf '%s' "$1" | sed "s/'/''/g" +} + +mysql_query() { + local query="$1" + MYSQL_PWD="${MYSQL_PASSWORD}" mysql \ + --batch \ + --raw \ + --skip-column-names \ + -h "${MYSQL_HOST}" \ + -u "${MYSQL_USER}" \ + "${MYSQL_DATABASE}" \ + -e "${query}" +} + +calculate_median_rep() { + local count + local median_index + local target_line + local sorted_rows + local median_rep + + count=$(mysql_query 'SELECT COUNT(DISTINCT affectedUser) FROM reaction;') + if (( count == 0 )); then + echo 'No reputation data found; refusing to calculate prices' >&2 + return 1 + fi + + sorted_rows=$(mysql_query ' + SELECT earned.affectedUser, (earned.total - COALESCE(spent.total, 0)) AS rep + FROM ( + SELECT affectedUser, SUM(value) AS total + FROM reaction + GROUP BY affectedUser + ) AS earned + LEFT JOIN ( + SELECT user, SUM(price) AS total + FROM purchase + GROUP BY user + ) AS spent ON spent.user = earned.affectedUser + ORDER BY rep DESC; + ') + + median_index=$(( (count + 1) / 2 )) + target_line=$(( median_index + 1 )) + median_rep=$(awk -F $'\t' -v target="${target_line}" 'NR == target { print $2 }' <<<"${sorted_rows}") + + if [[ -z "${median_rep}" ]]; then + median_rep=$(awk -F $'\t' 'END { print $2 }' <<<"${sorted_rows}") + fi + + printf '%s' "${median_rep}" +} + +main() { + local start_time + local team_id + local item_row + local item_id + local price_pct + local price + local median_rep + local -a teams + local -a items + local row + + require_command mysql + require_command awk + require_command sort + require_env TYPEORM_USERNAME + require_env TYPEORM_PASSWORD + require_env TYPEORM_DATABASE + + echo 'Beginning pricing job...' + start_time=$(date +%s) + echo 'Connecting to mysql DB...' + mysql_query 'SELECT 1;' >/dev/null + echo 'Connected!' + + echo 'Retrieving distinct teams...' + while IFS= read -r row; do + teams+=("${row}") + done < <(mysql_query 'SELECT DISTINCT(teamId) FROM slack_user;') + echo 'Teams retrieved!' + echo 'Retrieving all items...' + while IFS= read -r row; do + items+=("${row}") + done < <(mysql_query 'SELECT id, pricePct FROM item;') + echo 'Items retrieved!' + + median_rep=$(calculate_median_rep) + + for team_id in "${teams[@]}"; do + [[ -n "${team_id}" ]] || continue + + for item_row in "${items[@]}"; do + IFS=$'\t' read -r item_id price_pct <<<"${item_row}" + price=$(awk -v median="${median_rep}" -v pct="${price_pct}" 'BEGIN { printf "%.15f", median * pct }') + mysql_query "INSERT INTO price(itemId, teamId, price, itemIdId) VALUES(${item_id}, '$(sql_escape "${team_id}")', ${price}, ${item_id});" >/dev/null + done + + echo "Completed update for ${team_id}" + done + + echo "Completed job in $(( $(date +%s) - start_time )) seconds!" +} + +main "$@" From cfa6557ac5c56f97e242bc9857344db3899af714 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Sun, 22 Mar 2026 10:55:15 -0400 Subject: [PATCH 2/4] Update packages/jobs/pricing-job/script.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/jobs/pricing-job/script.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/jobs/pricing-job/script.sh b/packages/jobs/pricing-job/script.sh index b01fa404..9c777719 100755 --- a/packages/jobs/pricing-job/script.sh +++ b/packages/jobs/pricing-job/script.sh @@ -102,7 +102,6 @@ main() { require_command mysql require_command awk - require_command sort require_env TYPEORM_USERNAME require_env TYPEORM_PASSWORD require_env TYPEORM_DATABASE From b5a6bd50cb668020bda28028804e14f6438b5440 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Sun, 22 Mar 2026 10:57:26 -0400 Subject: [PATCH 3/4] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- packages/jobs/health-job/script.sh | 7 ++++++- packages/jobs/pricing-job/script.sh | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6c56b6a7..5d94a401 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ The scripts in `packages/jobs` are standalone bash jobs intended for cron-style - `packages/jobs/health-job/script.sh` requires `bash` and `curl` - `packages/jobs/fun-fact-job/script.sh` requires `bash`, `curl`, `jq`, and `mysql` -- `packages/jobs/pricing-job/script.sh` requires `bash`, `mysql`, `awk`, and `sort` +- `packages/jobs/pricing-job/script.sh` requires `bash`, `mysql`, and `awk` ### Available Scripts diff --git a/packages/jobs/health-job/script.sh b/packages/jobs/health-job/script.sh index da997832..5b9308d7 100755 --- a/packages/jobs/health-job/script.sh +++ b/packages/jobs/health-job/script.sh @@ -39,8 +39,13 @@ send_slack_message() { --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode "channel=${SLACK_CHANNEL}" \ --data-urlencode "text=${SLACK_MESSAGE}" \ - https://slack.com/api/chat.postMessage) + https://slack.com/api/chat.postMessage || true) + if [[ -z "${response_code:-}" ]]; then + echo "Slack API request failed: curl did not complete successfully" >&2 + rm -f "${response_body}" + return 1 + fi if [[ "${response_code}" != "200" ]]; then echo "Slack API request failed with HTTP ${response_code}" >&2 rm -f "${response_body}" diff --git a/packages/jobs/pricing-job/script.sh b/packages/jobs/pricing-job/script.sh index 9c777719..333e0251 100755 --- a/packages/jobs/pricing-job/script.sh +++ b/packages/jobs/pricing-job/script.sh @@ -78,7 +78,7 @@ calculate_median_rep() { ') median_index=$(( (count + 1) / 2 )) - target_line=$(( median_index + 1 )) + target_line=${median_index} median_rep=$(awk -F $'\t' -v target="${target_line}" 'NR == target { print $2 }' <<<"${sorted_rows}") if [[ -z "${median_rep}" ]]; then From 0ec8128c489d8ecd5289cdce43c9309e00d2f57f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:04:24 -0400 Subject: [PATCH 4/4] Fix bash job scripts: require_command checks, curl temp file leak, batched MySQL inserts (#187) * Initial plan * Fix code review feedback: require_command checks, temp file cleanup, batch MySQL inserts Co-authored-by: sfreeman422 <16405652+sfreeman422@users.noreply.github.com> Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/1c571688-b133-4945-9edc-a6d11e23df1d --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sfreeman422 <16405652+sfreeman422@users.noreply.github.com> --- packages/jobs/fun-fact-job/script.sh | 8 +++++++- packages/jobs/health-job/script.sh | 13 +++++++++++++ packages/jobs/pricing-job/script.sh | 12 ++++++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/jobs/fun-fact-job/script.sh b/packages/jobs/fun-fact-job/script.sh index ed483e63..f48fb95f 100755 --- a/packages/jobs/fun-fact-job/script.sh +++ b/packages/jobs/fun-fact-job/script.sh @@ -337,7 +337,13 @@ send_slack_message() { --header "Authorization: Bearer ${MUZZLE_BOT_TOKEN}" \ --header 'Content-Type: application/json; charset=utf-8' \ --data "${payload}" \ - https://slack.com/api/chat.postMessage) + https://slack.com/api/chat.postMessage || true) + + if [[ -z "${response_code:-}" ]]; then + echo "Slack API request failed: curl did not complete successfully" >&2 + rm -f "${response_file}" + return 1 + fi if [[ "${response_code}" != '200' ]] || ! jq -e '.ok == true' "${response_file}" >/dev/null 2>&1; then cat "${response_file}" >&2 diff --git a/packages/jobs/health-job/script.sh b/packages/jobs/health-job/script.sh index 5b9308d7..fa26bfd8 100755 --- a/packages/jobs/health-job/script.sh +++ b/packages/jobs/health-job/script.sh @@ -10,6 +10,15 @@ fi PATH=/usr/local/bin:/usr/bin:/bin:${PATH:-} +require_command() { + local command_name="$1" + + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "Missing required command: ${command_name}" >&2 + exit 1 + fi +} + HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:3000/health}" SLACK_CHANNEL="${SLACK_CHANNEL:-#muzzlefeedback}" SLACK_MESSAGE=':this-is-fine: `Moonbeam is experiencing some technical difficulties at the moment.` :this-is-fine:' @@ -89,6 +98,10 @@ check_health() { } main() { + require_command curl + require_command grep + require_command mktemp + if check_health; then exit 0 fi diff --git a/packages/jobs/pricing-job/script.sh b/packages/jobs/pricing-job/script.sh index 333e0251..0fbc1c6f 100755 --- a/packages/jobs/pricing-job/script.sh +++ b/packages/jobs/pricing-job/script.sh @@ -96,6 +96,7 @@ main() { local price_pct local price local median_rep + local sql_batch local -a teams local -a items local row @@ -125,18 +126,25 @@ main() { median_rep=$(calculate_median_rep) + sql_batch="" for team_id in "${teams[@]}"; do [[ -n "${team_id}" ]] || continue for item_row in "${items[@]}"; do IFS=$'\t' read -r item_id price_pct <<<"${item_row}" price=$(awk -v median="${median_rep}" -v pct="${price_pct}" 'BEGIN { printf "%.15f", median * pct }') - mysql_query "INSERT INTO price(itemId, teamId, price, itemIdId) VALUES(${item_id}, '$(sql_escape "${team_id}")', ${price}, ${item_id});" >/dev/null + sql_batch+="INSERT INTO price(itemId, teamId, price, itemIdId) VALUES(${item_id}, '$(sql_escape "${team_id}")', ${price}, ${item_id});"$'\n' done - echo "Completed update for ${team_id}" + echo "Queued update for ${team_id}" done + if [[ -n "${sql_batch}" ]]; then + echo 'Executing batch price inserts...' + mysql_query "START TRANSACTION; +${sql_batch}COMMIT;" || { echo 'Batch insert failed; transaction has been rolled back' >&2; return 1; } + fi + echo "Completed job in $(( $(date +%s) - start_time )) seconds!" }