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
89 changes: 88 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,88 @@
# Chrono-slack-app
# Chrono

Chrono is a Slack app that helps teams coordinate across time zones. It provides simple commands to convert times, check individual availability, and view current times for team members in a channel or user group.

---

## Features

### Slash Commands

- **`/convert [time] [from_timezone] [to_timezone]`**
Example: `/convert 3pm est pst`
Converts a time between two time zones. Inputs are not case sensitive.

- **`/timefor @user`**
Example: `/time @user`
Converts a time (assumed to be your current time in your local timezone) into the target user’s time zone.

- **`/teamclock` or `/teamclock @usergroup`**
Displays the current time for each member in the channel or user group. Indicates general availability (e.g. before 9 AM = unavailable).

### Workflows

- **Quick Convert**
DM-based version of `/convert`. Enter a time and time zone, receive the converted time.

- **Teammate Time**
DM-based version of `/time`. Enter a time and a user to receive the converted time in their zone.

---

## Setup & Installation

### 1. Create the Slack App

1. Visit: [https://api.slack.com/apps](https://api.slack.com/apps)
2. Choose "Create New App" and "From an app manifest"
3. Select a workspace and paste the contents of `manifest.json` into the JSON input field.
4. Click Next, review, and Create.
5. Click Install to Workspace and authorize the app.

### 2. Configure Environment Variables

Create a `.env` file in the project root directory with the following:

```
SLACK_BOT_TOKEN=your-bot-token
SLACK_APP_TOKEN=your-app-level-token

```

To get these:

- **Bot Token**: Found under **OAuth & Permissions** in the app settings. Copy the "Bot User OAuth Token".
- **App Token**: Found under **Basic Information > App-Level Tokens**. Create one with `connections:write` scope.

---

### 3. Run Chrono Locally

Make sure Node.js is installed.

Run:

```bash
git clone https://github.com/yourusername/chrono.git
cd chrono
npm install
node app.js
```

(Chrono uses Socket Mode)

---

### 4. Start Using Chrono

Once the app is running and installed:

- Add the bot to a channel:
```
/invite @Chrono
```

- Try out a command:
```
/convert 2pm est pst
```
39 changes: 39 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { App } = require('@slack/bolt');

// Import slash commands
const convertCommand = require("./commands/convertTime");
const timeForCommand = require("./commands/timeFor");
const teamClockCommand = require("./commands/teamClock");

// Import workflows
const {
handleTimeZoneConversionWF,
handleUserConversionWF,
} = require("./workflows/conversionWorkflows.js");

// Initializing Bolt app with Socket Mode
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
socketMode: true,
appToken: process.env.SLACK_APP_TOKEN,
});

// Handle app_mention events
app.event("app_mention", async ({ event, say }) => {
await say(`Hello there, <@${event.user}>!`);
});

// Register slash commands
app.command("/convert", convertCommand);
app.command("/timefor", timeForCommand);
app.command("/teamclock", teamClockCommand);

// Register workflow functions
app.function("Time_zone_converted_WF", handleTimeZoneConversionWF);
app.function("Userconv", handleUserConversionWF);

// Start Bolt app
(async () => {
await app.start();
app.logger.info("⚡️ Bolt app is running!");
})();
22 changes: 22 additions & 0 deletions commands/convertTime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Import logic file
const { convertTimeCommandText } = require("./convertTimeLogic");

const convertCommand = async ({ command, ack, respond, logger }) => {
try {
await ack();
const { error, result } = convertTimeCommandText(command.text);

if (error) {
await respond({ text: error });
return;
}

await respond({ text: result });

} catch (error) {
logger.error('Error handling convert command:', error);
}
};

module.exports = convertCommand;

75 changes: 75 additions & 0 deletions commands/convertTimeLogic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/* Handles the /convert slash command
* Converts a given time from a given timezone to a target timezone
* Expects input: '/convert <time> <from_zone> <to_zone>'
*/

const moment = require("moment-timezone"); // Plugin for Moment.js library
const { tzMap, TIME_FORMATS } = require("../utils/constants.js");

/* The `convertTimeCommandText` function parses
* and converts a time between time zones
*/
function convertTimeCommandText(text) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about adding proper JSdocs here to add information on what text is expected to be and what the outputs of the function is?

As an outsider it can sometimes be hard to determine this just from the code

// Split the input string into parts, trimming whitespace
// using a regular expression
const parts = text.trim().split(/\s+/);

// Need at least 3 parts: time, from_zone, and to_zone
if (parts.length < 3) {
return {
error:
"Invalid input format. Please use the `/convert <time> <from_zone> <to_zone>`\nExample: `/convert 3PM EST PST`",
};
}

// Calculates how many parts belong to the time value (can include spaces e.g. 12 30)
const timePartCount = parts.length - 2;
const timeVal = parts.slice(0, timePartCount).join(" ");
const fromZoneAbbr = parts[timePartCount];
const toZoneAbbr = parts[timePartCount + 1];

// Look up the full time zone names from the abbreviation map
// e.g. EST to America/New_York
const fromZone = tzMap[fromZoneAbbr.toUpperCase()];
const toZone = tzMap[toZoneAbbr.toUpperCase()];

// Validate to and from zones
if (!fromZone) {
return {
error: `Sorry, I don't recognize the time zone abbreviation ${fromZoneAbbr}`,
};
}

if (!toZone) {
return {
error: `Sorry, I don't recognize the time zone abbreviation ${toZoneAbbr}`,
};
}

// Get the current date and attach to the time input
const currentDate = moment().format("YYYY-MM-DD");
let naiveTime = null;

// Find a valid time format from the given input
for (const format of TIME_FORMATS) {
naiveTime = moment.tz(`${currentDate} ${timeVal}`, `YYYY-MM-DD ${format}`, fromZone);
if (naiveTime.isValid()) break;
}

if (!naiveTime || !naiveTime.isValid()) {
return {
error: "Invalid time provided. Please check the time format.",
};
}

const converted = naiveTime.clone().tz(toZone);

// Construct the response message showing original and converted time
const output_message = `${naiveTime.format("h:mm a")} ${fromZoneAbbr.toUpperCase()} ➡️ ${converted.format(
"h:mm a"
)} ${toZoneAbbr.toUpperCase()}`;

return { result: output_message };
}

module.exports = { convertTimeCommandText };
89 changes: 89 additions & 0 deletions commands/teamClock.js
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️‍🗨️ I like that logic for each command is separated, but the separation between teamClock and teamClockLogic isn't so clear to me and I might favor a single file for all of this?

The listeners pattern in samples can be useful too once projects gain enough functionalities ✨

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const { WebClient } = require("@slack/web-api");
const client = new WebClient(process.env.SLACK_BOT_TOKEN);
const { formatUserAvailabilityMessages } = require("../commands/teamClockLogic.js");

/* The `getUserIdsFromCommand` function gets
* user IDs from various Slack input types.
* Simplifies the user input parsing logic.
*/
async function getUserIdsFromCommand(command) {
const userInput = command.text.trim();
const channelId = command.channel_id;
let userIds = [];
let output_message = '';

try {
// Match a user group format using a regular expression
// E.g. input: <!subteam^ID|groupname>
const userGroupMatch = userInput.match(/<!subteam\^([A-Z0-9]+)\|?.*>/);

// If user group is provided, get its member user IDs
if (userGroupMatch) {
const userGroupId = userGroupMatch[1];
const groupMembersResult = await client.usergroups.users.list({ usergroup: userGroupId });
userIds = groupMembersResult.users || [];

// If not a user group, get the channel's member user IDs
} else if (userInput === "") {
const membersResult = await client.conversations.members({ channel: channelId });
userIds = membersResult.members || [];

// Invalid input
} else {
output_message = "Invalid input. Please use `/teamclock` or `/teamclock @usergroup`";
}
} catch (error) {
console.error("Error fetching user list:", error);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be overkill for the project but this function could accept some sort of context input parameter that contains a logger, this way your entire project could use the Bolt logger 💡

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit: Heh I realize now I'm making similar comments elsewhere - using the context value is a very interesting idea though! 🧠 ✨

output_message = "Sorry, I couldn't get the user list right now. Please try again later.";
}

return { userIds, output_message };
}

// Handles the /teamclock slash command
module.exports = async ({ command, ack, respond }) => {
await ack();

const { userIds, output_message } = await getUserIdsFromCommand(command);

if (output_message) {
await respond(output_message);
return;
}

if (!userIds.length) {
await respond("No users found.");
return;
}

// Use Slack API to get user details
const usersDataForProcessing = [];
for (const userId of userIds) {
try {
const result = await client.users.info({ user: userId });

// Don't include bots and deleted users
if (result.user && !result.user.is_bot && !result.user.deleted) {
usersDataForProcessing.push({
id: result.user.id,
real_name: result.user.real_name,
tz: result.user.tz,
});
}
} catch (error) {
console.error(`Error fetching info for user ${userId}:`, error);
}
}

// If no valid users could be processed
if (!usersDataForProcessing.length) {
await respond("Could not get valid user information for any members.");
return;
}

// Format the user availability messages using their time zones
const userMessages = formatUserAvailabilityMessages(usersDataForProcessing);

const message = userMessages.join("\n");
await respond(message);
};
65 changes: 65 additions & 0 deletions commands/teamClockLogic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* Handles the /teamclock slash command
* Generates an output message of every channel/user group
* member's time, timezone, and availability
* Expects input: '/teamclock' for channel
* Expects input: '/teamclock @usergroup' for usergroup
*/

const moment = require("moment-timezone");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⏳ The time happenings included can no doubt use functions from elsewhere and I'm wondering if the Intl.DateTimeFormat class was considered instead of moment-timezone?

AFAICT it might be a suitable replacement, albeit with not as concise syntax... But I do tend to prefer fewer dependencies when possible!


const timeRanges = [
{ start: "00:00", message: "Sleeping 😴" },
{ start: "09:00", message: "Available ✅" },
{ start: "16:00", message: "Getting late 🕓" },
{ start: "17:00", message: "After hours 🌙" },
];

/* The `teamClockCommandText` function determines
* a user's availability message based on a given time string.
*/
function teamClockCommandText(text) {
const time = moment(text, "HH:mm");
for (const range of timeRanges) {
const start = moment(range.start, "HH:mm");
const end = moment(range.end, "HH:mm");

if (end.isBefore(start)) {
// Range crosses midnight
if (time.isSameOrAfter(start) || time.isBefore(end)) {
return range.message;
}
} else {
// Standard range (
if (time.isSameOrAfter(start) && time.isBefore(end)) {
return range.message;
}
}
}
return "Unknown status";
}

// Processes a list of users and returns their formatted status messages.
function formatUserAvailabilityMessages(usersData) {
const messages = [];
for (const user of usersData) {
try {
const userTz = user.tz;
const curTime = moment.tz(userTz).format("HH:mm");
const curTimeFormatted = moment.tz(userTz).format("h:mma");
const status = teamClockCommandText(curTime);

// Format output message line
messages.push(`<@${user.id}>: ${curTimeFormatted} ⇔ ${status}`);
} catch (error) {
//console.error(`Error processing user ${user.real_name || user.id}:`, error);
messages.push(`Error getting status for user ${user.real_name || user.id}`);
}
}
return messages;
}

module.exports = {
teamClockCommandText,
formatUserAvailabilityMessages,
timeRanges
};
Loading