-
Notifications
You must be signed in to change notification settings - Fork 0
Initial Code Review #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ``` |
| 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!"); | ||
| })(); |
| 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; | ||
|
|
| 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) { | ||
| // 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 }; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The |
| 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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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); | ||
| }; | ||
| 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"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
| }; | ||
There was a problem hiding this comment.
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
textis 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