Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
node_modules/
.git/
.env
*.env
.env.example
.gitignore
LICENSE
README.md
eslint.config.js
scripts/
test/
.github/
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Slack bot token (xoxb-...) used to post messages.
SLACK_BOT_TOKEN=xoxb-YOURBOTTOKENHERE
# Slack app Signing Secret (Basic Information -> App Credentials). Bolt uses it
# to verify the Yes/No button interactions. This is NEW: the old express-slack
# setup did not verify button requests.
SLACK_SIGNING_SECRET=YOURSIGNINGSECRETHERE
# Channel IDs (were hardcoded in server.js).
AIR_CHANNEL=G6XGMATUP
DISPATCH_CHANNEL=GAG3D0EBF
# Shared secret the external dispatch system sends as the `verification` field.
VERIFICATION_TOKEN=shared-secret-here
# Port the server listens on (defaults to 5939, the original port).
PORT=5939
# Out-of-service check config. NOTE: the OOS override is currently disabled
# because the original code never awaited it (it never worked). Kept here for
# when the feature is fixed and enabled. See the PR.
OOS_URL=
OOS_TOKEN=
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [master, modernization]
pull_request:
branches: [master]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Test
run: npm test

- name: Build Docker image (amd64)
run: docker build --platform linux/amd64 -t air:ci .
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
/var.js
node_modules/
.env
*.env

# Deploy secrets
scripts/config.sh
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM node:24-alpine

WORKDIR /usr/src/app

ENV NODE_ENV=production

COPY package*.json ./
RUN npm ci --omit=dev

COPY server.js lib.js ./

EXPOSE 5939

# Stateless service — safe to run as the image's built-in non-root user.
USER node

# PORT may be overridden at runtime (defaults to 5939); check whatever it is.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${PORT:-5939}/health" >/dev/null 2>&1 || exit 1

CMD ["node", "server.js"]
Empty file removed data.json
Empty file.
39 changes: 39 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const js = require("@eslint/js");
const globals = require("globals");

module.exports = [
js.configs.recommended,
{
files: ["**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "commonjs",
globals: {
...globals.node,
},
},
rules: {
"linebreak-style": ["error", "unix"],
quotes: ["error", "double"],
semi: ["error", "always"],
"no-console": "off",
"no-var": "warn",
"no-case-declarations": "off",
"semi-spacing": ["error", { before: false, after: true }],
indent: ["error", 2],
"no-lonely-if": "error",
"no-multiple-empty-lines": ["error", { max: 1 }],
"func-style": ["error", "declaration", { allowArrowFunctions: true }],
"require-await": "error",
"prefer-arrow-callback": "error",
},
},
{
files: ["test/**/*.js"],
languageOptions: {
globals: {
...globals.jest,
},
},
},
];
194 changes: 194 additions & 0 deletions lib.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Pure helpers for AIR, with no Slack/network dependency, so they can be unit
// tested. Behavior is preserved verbatim from the original server.js.

// Orange used on dispatch attachments, green used on "responding" status.
const DISPATCH_COLOR = "#F35A00";
const RESPONDING_COLOR = "#7CD197";

// True if the current time is before/after the given hour:minute.
// direction "lt" => now is earlier than hr:min; "gt" => now is later.
function compareTime(hr, min, direction, now = new Date()) {
const nowhr = now.getHours();
const nowmin = now.getMinutes();
if (direction == "lt") {
return nowhr < hr || (nowhr == hr && nowmin < min);
} else if (direction == "gt") {
return nowhr > hr || (nowhr == hr && nowmin > min);
}
}

// The day-crew response window: between 05:55 and 18:05.
function isDaytimeWindow(now = new Date()) {
return compareTime(5, 55, "gt", now) && compareTime(18, 5, "lt", now);
}

// "YYYY-MM-DD at HH:MM:SS" for the current time.
function makeDate(now = new Date()) {
const pad = (n) => (n < 10 ? "0" + n : "" + n);
return [
now.getFullYear(),
"-",
pad(now.getMonth() + 1),
"-",
pad(now.getDate()),
" at ",
pad(now.getHours()),
":",
pad(now.getMinutes()),
":",
pad(now.getSeconds()),
].join("");
}

// "F. Lastname" from a Slack user profile.
function abbreviateName(profile) {
return profile.first_name.charAt(0).toUpperCase() + ". " + profile.last_name;
}

// The interactive dispatch message posted to the responding channel during the
// day crew window. Buttons are Block Kit (inside a colored attachment so the
// orange color bar is preserved). action_id/value drive the response handler.
function buildRespondingMessage(channel, dispatch, date) {
return {
channel,
unfurl_links: true,
text: "RPI Ambulance dispatched on " + date,
attachments: [
{
color: DISPATCH_COLOR,
fallback: dispatch,
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `RPI Ambulance dispatched on ${date}\n*${dispatch}*\nAre you responding?`,
},
},
{
type: "actions",
elements: [
{
type: "button",
action_id: "responding_yes",
value: "yes",
style: "danger",
text: { type: "plain_text", text: "Yes" },
},
{
type: "button",
action_id: "responding_no",
value: "no",
text: { type: "plain_text", text: "No" },
},
],
},
],
},
],
};
}

// The non-interactive dispatch notice (night crew / no response needed).
function buildNightCrewMessage(channel, dispatch, date) {
return {
channel,
unfurl_links: true,
text: "RPI Ambulance dispatched on " + date,
attachments: [
{
color: DISPATCH_COLOR,
fallback: dispatch,
title: dispatch,
text: "Night crew call. No response is needed.",
},
],
};
}

// The plain notice posted to the dispatch channel during the day.
function buildDispatchChannelMessage(channel, dispatch, date) {
return {
channel,
unfurl_links: false,
text: "RPI Ambulance dispatched on " + date,
attachments: [{ fallback: dispatch, title: dispatch }],
};
}

// The notice posted to the dispatch channel at night.
function buildNightDispatchChannelMessage(channel, dispatch, date) {
return {
channel,
unfurl_links: false,
text: "RPI Ambulance dispatched on " + date,
attachments: [
{
color: DISPATCH_COLOR,
fallback: dispatch,
title: dispatch,
text: "Night crew call. No response is needed.",
},
],
};
}

// The Rensselaer County longtone notice (informational, no buttons).
function buildLongtoneMessage(channel, dispatch, date) {
return {
channel,
unfurl_links: true,
text: "Rensslaer County longtone on " + date,
attachments: [
{
color: DISPATCH_COLOR,
fallback: dispatch,
title: dispatch,
text: "Rensslaer County longtone on " + date,
},
],
};
}

// The "<name> is RESPONDING / is NOT RESPONDING" status message.
function buildStatusMessage(channel, name, responding) {
if (responding) {
return {
channel,
unfurl_links: true,
attachments: [
{
color: RESPONDING_COLOR,
fallback: name + " is RESPONDING",
text: "*" + name + "*" + " is *RESPONDING*",
mrkdwn_in: ["text"],
},
],
};
}
return {
channel,
unfurl_links: true,
attachments: [
{
fallback: name + " is not responding",
text: name + " is NOT RESPONDING",
},
],
};
}

module.exports = {
DISPATCH_COLOR,
RESPONDING_COLOR,
compareTime,
isDaytimeWindow,
makeDate,
abbreviateName,
buildRespondingMessage,
buildNightCrewMessage,
buildDispatchChannelMessage,
buildNightDispatchChannelMessage,
buildLongtoneMessage,
buildStatusMessage,
};
Loading
Loading