├── .cfignore ├── .dependabot └── config.yml ├── .env-sample ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── codeql.yml │ ├── on_dependabot_auto_approve.yml │ ├── on_dispatch_deploy_test.yml │ ├── on_pull_request.yml │ ├── on_pull_request_inclusion_bot_sync.yml │ ├── on_pull_request_tock_ops.yml │ ├── on_push_main.yml │ ├── on_schedule_monthly.yml │ ├── on_schedule_nightly.yml │ ├── reusable_build.yml │ ├── reusable_deploy.yml │ └── reusable_test.yml ├── .gitignore ├── CHANGELOG.inclusion_bot.md ├── CONTRIBUTING.md ├── Dockerfile ├── InclusionBot.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── awesomobot.png ├── config ├── a11ybot-resources.json ├── q-expand.csv └── slack-random-response.json ├── dev.yml ├── docker-compose.yml ├── lts.js ├── lts.test.js ├── manifest.yml ├── package-lock.json ├── package.json ├── prod.yml ├── src ├── brain.js ├── brain.test.js ├── env.js ├── env.test.js ├── include.test.js ├── main.js ├── scripts │ ├── a11y.js │ ├── a11y.test.js │ ├── angry-tock.js │ ├── angry-tock.test.js │ ├── bio-art.js │ ├── bio-art.test.js │ ├── coffeemate.js │ ├── coffeemate.test.js │ ├── dad-joke.js │ ├── dad-joke.json │ ├── dad-joke.test.js │ ├── define.js │ ├── define.test.js │ ├── dot-gov.js │ ├── dot-gov.test.js │ ├── erg-inviter.js │ ├── erg-inviter.test.js │ ├── erg-inviter.yaml │ ├── erg-inviter.yaml.test.js │ ├── evermarch.js │ ├── evermarch.test.js │ ├── fancy-font.js │ ├── fancy-font.test.js │ ├── federal-holidays-reminder.js │ ├── federal-holidays-reminder.test.js │ ├── federal-holidays.js │ ├── federal-holidays.test.js │ ├── handbook.js │ ├── handbook.test.js │ ├── help.js │ ├── help.test.js │ ├── home.js │ ├── home.test.js │ ├── i-voted.js │ ├── i-voted.test.js │ ├── inclusion-bot.js │ ├── inclusion-bot.test.js │ ├── inclusion-bot.yaml │ ├── inclusion-bot.yaml.test.js │ ├── opm_status.js │ ├── opm_status.test.js │ ├── opt-out.js │ ├── opt-out.test.js │ ├── optimistic-tock.js │ ├── optimistic-tock.test.js │ ├── pugs.js │ ├── pugs.test.js │ ├── q-expand.js │ ├── q-expand.test.js │ ├── random-responses.js │ ├── random-responses.test.js │ ├── three-paycheck-month.js │ ├── three-paycheck-month.test.js │ ├── timezone.js │ ├── timezone.test.js │ ├── tock-line.js │ ├── tock-line.test.js │ ├── tock-ops-report.js │ ├── tock-ops-report.test.js │ ├── travel-team.js │ ├── travel-team.test.js │ ├── us-code.js │ ├── us-code.test.js │ ├── zen.js │ └── zen.test.js └── utils │ ├── cache.js │ ├── cache.test.js │ ├── dates.js │ ├── dates.test.js │ ├── helpMessage.js │ ├── helpMessage.test.js │ ├── holidays.js │ ├── holidays.test.js │ ├── homepage.js │ ├── homepage.test.js │ ├── index.js │ ├── index.test.js │ ├── optOut.js │ ├── optOut.test.js │ ├── sample.js │ ├── sample.test.js │ ├── slack.js │ ├── slack.test.js │ ├── stats.js │ ├── stats.test.js │ ├── test.js │ ├── tock.js │ └── tock.test.js └── sync-inclusion-bot-words.js /.cfignore: -------------------------------------------------------------------------------- 1 | .DS_Store* 2 | dump.rdb 3 | slack.yml 4 | .circleci 5 | .dependabot 6 | .github 7 | .env 8 | .vscode 9 | coverage 10 | *.test.js -------------------------------------------------------------------------------- /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "javascript" 4 | directory: "/" 5 | update_schedule: "live" 6 | allowed_updates: 7 | - match: 8 | update_type: "security" -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | # This is required to connect. 2 | SLACK_TOKEN= 3 | SLACK_SIGNING_SECRET= 4 | 5 | # These are required for Angry and Optimistic Tock scripts 6 | TOCK_API= 7 | TOCK_TOKEN= 8 | 9 | # These are overridden by docker-compose 10 | DATABASE_URL= 11 | LOG_LEVEL= 12 | PORT= 13 | 14 | # These configure Angry Tock's behavior 15 | ANGRY_TOCK_FIRST_TIME= 16 | ANGRY_TOCK_REPORT_TO= 17 | ANGRY_TOCK_SECOND_TIME= 18 | ANGRY_TOCK_TIMEZONE= 19 | 20 | # These configure holiday reminders 21 | HOLIDAY_REMINDER_CHANNEL= 22 | HOLIDAY_REMINDER_TIME= 23 | HOLIDAY_REMINDER_TIMEZONE= 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["airbnb-base", "prettier"], 3 | rules: { 4 | "no-console": 0, 5 | // This rule is copied from the Airbnb config, but we remove the prohibition 6 | // on ForOf statements because they are natively supported in Node.js. The 7 | // remaining prohibitions are still good, though, so we don't want to just 8 | // completely disable the rule. 9 | "no-restricted-syntax": [ 10 | "error", 11 | { 12 | selector: "ForInStatement", 13 | message: 14 | "for..in loops iterate over the entire prototype chain, which is virtually never what you want. Use Object.{keys,values,entries}, and iterate over the resulting array.", 15 | }, 16 | { 17 | selector: "LabeledStatement", 18 | message: 19 | "Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.", 20 | }, 21 | { 22 | selector: "WithStatement", 23 | message: 24 | "`with` is disallowed in strict mode because it makes code impossible to predict and optimize.", 25 | }, 26 | ], 27 | "prefer-destructuring": [0], 28 | }, 29 | env: { 30 | es6: true, 31 | node: true, 32 | }, 33 | parserOptions: { ecmaVersion: 2021 }, 34 | overrides: [ 35 | { 36 | files: ["src/**/*.test.js", "src/utils/test.js", "lts.test.js"], 37 | env: { 38 | jest: true, 39 | }, 40 | }, 41 | ], 42 | }; 43 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @18F/charlie-maintainers 2 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Describe what this pull request is doing. If there are associated issues, 2 | cite those here as well. 3 | 4 | --- 5 | 6 | Checklist: 7 | 8 | - [ ] Code has been formatted with prettier 9 | - [ ] The [OAuth](https://github.com/18F/charlie/wiki/OAuthEventsAndScopes) wiki 10 | page has been updated if Charlie needs any new OAuth events or scopes 11 | - [ ] The [Environment Variables](https://github.com/18F/charlie/wiki/EnvironmentVariables) 12 | wiki page has been updated if new environment variables were introduced 13 | or existing ones changed 14 | - [ ] The dev wiki has been updated, e.g.: 15 | - local development processes have changed 16 | - major development workflows have changed 17 | - internal utilities or APIs have changed 18 | - testing or deployment processes have changed 19 | - [ ] If appropriate, the NIST 800-218 documentation has been updated 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '28 7 * * 0' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Initialize CodeQL 22 | uses: github/codeql-action/init@v2 23 | with: 24 | queries: security-and-quality 25 | 26 | - name: Perform CodeQL Analysis 27 | uses: github/codeql-action/analyze@v2 28 | -------------------------------------------------------------------------------- /.github/workflows/on_dependabot_auto_approve.yml: -------------------------------------------------------------------------------- 1 | name: auto-approve dependabot PRs 2 | 3 | on: 4 | pull_request_target: 5 | 6 | jobs: 7 | run: 8 | name: auto-approve dependabot PRs 9 | runs-on: ubuntu-latest 10 | if: ${{ github.actor == 'dependabot[bot]' }} 11 | env: 12 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: gh pr review "$GITHUB_HEAD_REF" --approve 16 | - run: gh pr merge "$GITHUB_HEAD_REF" --auto --squash 17 | -------------------------------------------------------------------------------- /.github/workflows/on_dispatch_deploy_test.yml: -------------------------------------------------------------------------------- 1 | name: deploy to testing 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | uses: ./.github/workflows/reusable_build.yml 9 | 10 | deploy: 11 | needs: [build] 12 | uses: ./.github/workflows/reusable_deploy.yml 13 | with: 14 | environment: dev 15 | secrets: inherit 16 | -------------------------------------------------------------------------------- /.github/workflows/on_pull_request.yml: -------------------------------------------------------------------------------- 1 | name: pull request 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | uses: ./.github/workflows/reusable_build.yml 9 | 10 | test: 11 | needs: [build] 12 | uses: ./.github/workflows/reusable_test.yml 13 | -------------------------------------------------------------------------------- /.github/workflows/on_pull_request_inclusion_bot_sync.yml: -------------------------------------------------------------------------------- 1 | name: inclusion bot updates 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - InclusionBot.md 7 | 8 | jobs: 9 | synchronize: 10 | name: Synchronize Inclusion Bot word lists 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Rather than use a personal access token to interact with the project, we 15 | # can use this GitHub App. There's an API for exchanging app credentials 16 | # for a short-term token, and we use that API here. 17 | - name: get token 18 | uses: tibdex/github-app-token@v1 19 | id: app_token 20 | with: 21 | app_id: ${{ secrets.APP_ID }} 22 | installation_id: ${{ secrets.APP_INSTALLATION_ID }} 23 | private_key: ${{ secrets.APP_PRIVATE_KEY }} 24 | 25 | - uses: actions/checkout@v4 26 | with: 27 | # Use the branch commit instead of the PR merge commit 28 | ref: ${{ github.event.pull_request.head.ref }} 29 | # Use app credentials for the token, so that any commits generated by 30 | # this job will trigger a rebuild, so the thing can be merged 31 | token: ${{ steps.app_token.outputs.token }} 32 | 33 | - uses: actions/setup-node@v4 34 | with: 35 | node-version: 22 36 | - run: npm ci 37 | 38 | - run: npx -y prettier -w InclusionBot.md 39 | - run: node sync-inclusion-bot-words.js 40 | 41 | - if: failure() 42 | uses: actions/github-script@v7 43 | with: 44 | script: | 45 | github.rest.issues.createComment({ 46 | issue_number: context.issue.number, 47 | owner: context.repo.owner, 48 | repo: context.repo.repo, 49 | body: '🚨 The Inclusion Bot word lists could not be synchronized. Please check the syntax of the markdown file.' 50 | }); 51 | 52 | # Check if anything has changed. This will exit with 0 if there is nothing 53 | # in the diff, or non-zero if anything has changed. 54 | - run: if git diff --exit-code --quiet; then echo "::set-output name=changes::no"; else echo "::set-output name=changes::yes"; fi 55 | id: diff 56 | 57 | # If anything changed, commit it. 58 | - if: steps.diff.outputs.changes == 'yes' 59 | uses: EndBug/add-and-commit@v9 60 | with: 61 | message: synchronize word lists 62 | 63 | - if: steps.diff.outputs.changes == 'yes' 64 | uses: actions/github-script@v7 65 | with: 66 | script: | 67 | github.rest.issues.createComment({ 68 | issue_number: context.issue.number, 69 | owner: context.repo.owner, 70 | repo: context.repo.repo, 71 | body: `The Inclusion Bot word lists have been synchronized. @${context.actor}, please pull the last commit before pushing any more changes to this branch.` 72 | }); 73 | -------------------------------------------------------------------------------- /.github/workflows/on_pull_request_tock_ops.yml: -------------------------------------------------------------------------------- 1 | name: tock ops report changes 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - src/scripts/tock-ops-report.js 7 | - src/scripts/tock-ops-report.test.js 8 | 9 | jobs: 10 | comment: 11 | name: leave a comment 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/github-script@v7 16 | with: 17 | script: | 18 | github.rest.issues.createComment({ 19 | issue_number: context.issue.number, 20 | owner: context.repo.owner, 21 | repo: context.repo.repo, 22 | body: "It looks like you're updating the Tock report. Please ensure that the 18F director of operations is aware of these changes because the Tock report is used by the ops team and needs to function." 23 | }); 24 | -------------------------------------------------------------------------------- /.github/workflows/on_push_main.yml: -------------------------------------------------------------------------------- 1 | name: push to main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | uses: ./.github/workflows/reusable_build.yml 11 | 12 | test: 13 | needs: [build] 14 | uses: ./.github/workflows/reusable_test.yml 15 | 16 | deploy: 17 | needs: [test] 18 | uses: ./.github/workflows/reusable_deploy.yml 19 | with: 20 | environment: prod 21 | secrets: inherit 22 | 23 | release: 24 | name: publish release 25 | needs: [deploy] 26 | runs-on: ubuntu-latest 27 | container: node:22 28 | permissions: 29 | contents: write 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: actions/cache@v3 33 | with: 34 | path: ./node_modules 35 | key: 18f-bot-${{ runner.os }}-${{ hashFiles('package.json') }}-v1 36 | - uses: actions/cache@v3 37 | id: npmcache 38 | with: 39 | path: ./npm-cache 40 | key: 18f-bot-${{ runner.os }}-npmcache-${{ hashFiles('package.json') }}-v1 41 | - name: get current time 42 | id: time 43 | run: | 44 | echo "::set-output name=time::$(date +%Y-%m-%d_%H-%M-%S)" 45 | echo "::set-output name=human::$(date '+%Y-%m-%d, %H:%M:%S')" 46 | - name: zip bundle 47 | uses: byteever/action-build-zip@e42976f29f487a742e0e65aee89375e23f080ada 48 | id: zip 49 | - name: compute zip hash 50 | id: hash 51 | run: | 52 | echo "::set-output name=sha::$(shasum -a 256 ${{ steps.zip.outputs.zip_path }} | cut -d' ' -f1)" 53 | - uses: softprops/action-gh-release@v1 54 | with: 55 | files: ${{ steps.zip.outputs.zip_path }} 56 | name: ${{ steps.time.outputs.human }} 57 | body: | 58 | Bundle hash (SHA256): ${{ steps.hash.outputs.sha }} 59 | generate_release_notes: true 60 | tag_name: ${{ steps.time.outputs.time }} 61 | -------------------------------------------------------------------------------- /.github/workflows/on_schedule_monthly.yml: -------------------------------------------------------------------------------- 1 | name: monthly LTS check 2 | 3 | on: 4 | schedule: 5 | # 7pm UTC, 15th of every month 6 | # Scheduled to hopefully be during TTS business days most of the time 7 | - cron: "0 19 15 * *" 8 | 9 | jobs: 10 | lts-check: 11 | name: current LTS check 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 22 19 | 20 | - run: node lts.js 21 | -------------------------------------------------------------------------------- /.github/workflows/on_schedule_nightly.yml: -------------------------------------------------------------------------------- 1 | name: nightly restage 2 | 3 | on: 4 | schedule: 5 | # 3am UTC 6 | - cron: "0 3 * * *" 7 | 8 | jobs: 9 | restage: 10 | runs-on: ubuntu-latest 11 | container: governmentpaas/cf-cli 12 | steps: 13 | - name: restage 14 | env: 15 | CF_API: ${{ secrets.CF_API }} 16 | CF_APP: ${{ secrets.CF_APP }} 17 | CF_ORG: ${{ secrets.CF_ORG }} 18 | CF_PASSWORD: ${{ secrets.CF_PASSWORD }} 19 | CF_SPACE: ${{ secrets.CF_SPACE }} 20 | CF_SPACE_DEV: ${{ secrets.CF_SPACE_DEV }} 21 | CF_USERNAME: ${{ secrets.CF_USERNAME }} 22 | 23 | # Log into prod, restage Charlie, then switch to dev and stop that 24 | # instance, if it's running 25 | run: | 26 | cf login -a $CF_API -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE 27 | cf restage $CF_APP 28 | cf target -s $CF_SPACE_DEV 29 | cf stop $CF_APP 30 | cf delete-service charlie-brain -f 31 | -------------------------------------------------------------------------------- /.github/workflows/reusable_build.yml: -------------------------------------------------------------------------------- 1 | name: build charlie 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | build: 8 | name: build 9 | runs-on: ubuntu-latest 10 | container: node:22 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/cache@v3 14 | id: depcache 15 | with: 16 | path: ./node_modules 17 | key: 18f-bot-${{ runner.os }}-${{ hashFiles('package.json') }}-v1 18 | - uses: actions/cache@v3 19 | id: npmcache 20 | with: 21 | path: ./npm-cache 22 | key: 18f-bot-${{ runner.os }}-npmcache-${{ hashFiles('package.json') }}-v1 23 | - name: install dependencies 24 | if: steps.depcache.outputs.cache-hit != 'true' 25 | run: npm ci --cache npm-cache 26 | -------------------------------------------------------------------------------- /.github/workflows/reusable_deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | environment: 7 | required: true 8 | type: string 9 | 10 | jobs: 11 | build: 12 | uses: ./.github/workflows/reusable_build.yml 13 | 14 | deploy: 15 | name: ${{ inputs.environment }} 16 | runs-on: ubuntu-latest 17 | container: governmentpaas/cf-cli 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/cache@v3 21 | with: 22 | path: ./node_modules 23 | key: 18f-bot-${{ runner.os }}-${{ hashFiles('package.json') }}-v1 24 | - uses: actions/cache@v3 25 | id: npmcache 26 | with: 27 | path: ./npm-cache 28 | key: 18f-bot-${{ runner.os }}-npmcache-${{ hashFiles('package.json') }}-v1 29 | - name: add extra deployment steps for dev 30 | id: devSteps 31 | if: ${{ inputs.environment == 'dev' }} 32 | env: 33 | CF_API: ${{ secrets.CF_API }} 34 | CF_ORG: ${{ secrets.CF_ORG }} 35 | CF_PASSWORD: ${{ secrets.CF_PASSWORD }} 36 | CF_SPACE: ${{ inputs.environment }} 37 | CF_USERNAME: ${{ secrets.CF_USERNAME }} 38 | run: | 39 | cf login -a $CF_API -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE 40 | cf create-service aws-rds micro-psql charlie-brain 41 | STATUS="$(cf service charlie-brain | grep " status:" | awk -F ":" '{print $2}' | xargs)" 42 | while [ "$STATUS" != "create succeeded" ] 43 | do 44 | echo "Waiting for database service to be ready..." 45 | sleep 10 46 | STATUS="$(cf service charlie-brain | grep " status:" | awk -F ":" '{print $2}' | xargs echo)" 47 | done 48 | - name: push to cloud.gov 49 | env: 50 | CF_API: ${{ secrets.CF_API }} 51 | CF_ORG: ${{ secrets.CF_ORG }} 52 | CF_PASSWORD: ${{ secrets.CF_PASSWORD }} 53 | CF_SPACE: ${{ inputs.environment }} 54 | CF_USERNAME: ${{ secrets.CF_USERNAME }} 55 | run: | 56 | cf login -a $CF_API -u $CF_USERNAME -p $CF_PASSWORD -o $CF_ORG -s $CF_SPACE 57 | cf push -f manifest.yml --vars-file ./${{ inputs.environment }}.yml 58 | -------------------------------------------------------------------------------- /.github/workflows/reusable_test.yml: -------------------------------------------------------------------------------- 1 | name: test and lint 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | lint: 8 | name: lint 9 | runs-on: ubuntu-latest 10 | container: node:22 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/cache@v3 14 | with: 15 | path: ./node_modules 16 | key: 18f-bot-${{ runner.os }}-${{ hashFiles('package.json') }}-v1 17 | - name: lint 18 | run: npm run lint 19 | 20 | format: 21 | name: verify formatting 22 | runs-on: ubuntu-latest 23 | container: node:22 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/cache@v3 27 | with: 28 | path: ./node_modules 29 | key: 18f-bot-${{ runner.os }}-${{ hashFiles('package.json') }}-v1 30 | - name: verify formatting 31 | run: npm run format-test 32 | 33 | test: 34 | name: unit tests 35 | runs-on: ubuntu-latest 36 | container: node:22 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: actions/cache@v3 40 | with: 41 | path: ./node_modules 42 | key: 18f-bot-${{ runner.os }}-${{ hashFiles('package.json') }}-v1 43 | - name: run tests 44 | run: npm test 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store* 2 | .env 3 | .nyc_output 4 | .vscode 5 | coverage 6 | node_modules 7 | -------------------------------------------------------------------------------- /CHANGELOG.inclusion_bot.md: -------------------------------------------------------------------------------- 1 | # Inclusion Bot change history 2 | 3 | - **May 2020:** Inclusion Bot (then "Guys Bot") is moved into Charlie, replacing the 4 | previous implementation as a Slackbot autoresponder. We decided to make the 5 | move because it allowed us to be more targeted with triggers, such as ignoring 6 | "guys" in quotes. ([#208](https://github.com/18F/charlie/pull/208)) 7 | 8 | - **May 2020:** Made the bot message ephemeral, which means only the person who 9 | triggered the bot will see its response. The bot puts an emoji on the 10 | triggering message as a signal to the rest of the team that the bot has 11 | responded. ([#218](https://github.com/18F/charlie/pull/218)) 12 | 13 | - **December 2020:** Inclusion Bot is expanded to support responding to a wider 14 | variety of triggers and different alternatives. It also picks up on multiple 15 | triggers within a single message so it can respond to all of them at once. The 16 | list of triggers and alternative suggestions is pulled from 17 | [the Inclusion Bot document](https://docs.google.com/document/d/1MMA7f6uUj-EctzhtYNlUyIeza6R8k4wfo1OKMDAgLog/edit#) 18 | (**content warning:** offensive language). 19 | ([#258](https://github.com/18F/charlie/pull/258)) 20 | 21 | - **June 2021:** Inclusion Bot is expanded to include an optional modal window 22 | explaining why certain phrases or terms are problematic. 23 | ([#299](https://github.com/18F/charlie/pull/299)) 24 | 25 | - **October 2021:** Inclusion Bot's message is tweaked to seem less like it is 26 | accusing someone of being non-inclusive. This is to address a common feeling 27 | among some users that they are being "called out" by the bot. The greeting 28 | text is moved ahead of the list of triggering words, and a link to 29 | [the new Inclusion Bot document](https://docs.google.com/document/d/1iQT7Gy0iQa7sopBP0vB3CZ56GhyYrDNUzLdoWOowSHs/edit) 30 | is displayed. ([#320](https://github.com/18F/charlie/pull/320)) 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Welcome! 2 | 3 | We're so glad you're thinking about contributing to an 18F open source project! 4 | If you're unsure about anything, just ask -- or submit the issue or pull request 5 | anyway. The worst that can happen is you'll be politely asked to change 6 | something. We love all friendly contributions. 7 | 8 | We want to ensure a welcoming environment for all of our projects. Our staff 9 | follow the [18F Code of Conduct](https://github.com/18F/code-of-conduct/blob/master/code-of-conduct.md) 10 | and all contributors should do the same. 11 | 12 | We encourage you to read this project's CONTRIBUTING policy (you are here), its 13 | [LICENSE](LICENSE.md), and its [README](README.md). 14 | 15 | If you have any questions or want to read more, check out the 16 | [18F Open Source Policy GitHub repository](https://github.com/18f/open-source-policy), 17 | or just [send us an email](mailto:18f@gsa.gov). 18 | 19 | ## Development 20 | 21 | See [our wiki](https://github.com/18F/charlie/wiki) for developer documentation. 22 | 23 | ## Public domain 24 | 25 | This project is in the worldwide [public domain](LICENSE.md). 26 | 27 | This project is in the public domain within the United States, and copyright and 28 | related rights in the work worldwide are waived through the [CC0 1.0 Universal public domain dedication](https://creativecommons.org/publicdomain/zero/1.0/). 29 | 30 | All contributions to this project will be released under the CC0 dedication. By 31 | submitting a pull request, you are agreeing to comply with this waiver of 32 | copyright interest. 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22 2 | 3 | RUN mkdir /app 4 | WORKDIR /app 5 | 6 | ADD ./package.json . 7 | ADD ./package-lock.json . 8 | 9 | RUN npm ci 10 | 11 | CMD npm run start-dev 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | As a work of the United States Government, this project is in the 2 | public domain within the United States. 3 | 4 | Additionally, we waive copyright and related rights in the work 5 | worldwide through the CC0 1.0 Universal public domain dedication. 6 | 7 | ## CC0 1.0 Universal Summary 8 | 9 | This is a human-readable summary of the [Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 10 | 11 | ### No Copyright 12 | 13 | The person who associated a work with this deed has dedicated the work to 14 | the public domain by waiving all of his or her rights to the work worldwide 15 | under copyright law, including all related and neighboring rights, to the 16 | extent allowed by law. 17 | 18 | You can copy, modify, distribute and perform the work, even for commercial 19 | purposes, all without asking permission. 20 | 21 | ### Other Information 22 | 23 | In no way are the patent or trademark rights of any person affected by CC0, 24 | nor are the rights that other persons may have in the work or in how the 25 | work is used, such as publicity or privacy rights. 26 | 27 | Unless expressly stated otherwise, the person who associated a work with 28 | this deed makes no warranties about the work, and disclaims liability for 29 | all uses of the work, to the fullest extent permitted by applicable law. 30 | When using or citing the work, you should not imply endorsement by the 31 | author or the affirmer. 32 | -------------------------------------------------------------------------------- /awesomobot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/18F/charlie/1eb89e44adad3fbd52da05d26cde96a5c0f3c819/awesomobot.png -------------------------------------------------------------------------------- /config/slack-random-response.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "botName": "Alumni Dag Bot", 4 | "defaultEmoji": ":dag:", 5 | "trigger": "alumni d(o|a)g facts?", 6 | "responseUrl": "https://raw.githubusercontent.com/18F/charlie-slack-bot-facts/main/dags-alumni.json" 7 | }, 8 | { 9 | "botName": "Bird Bot", 10 | "defaultEmoji": ":yelling-bird:", 11 | "trigger": "bird facts?", 12 | "responseUrl": "https://raw.githubusercontent.com/18F/charlie-slack-bot-facts/main/birds.json" 13 | }, 14 | { 15 | "botName": "Dag Bot", 16 | "defaultEmoji": ":dog:", 17 | "trigger": "(? brain.get(key); 7 | 8 | const set = async (key, value) => { 9 | brain.set(key, value); 10 | await client.query( 11 | "INSERT INTO brain (key, value) VALUES($1, $2) ON CONFLICT (key) DO UPDATE SET value=$2", 12 | [key, JSON.stringify(value)], 13 | ); 14 | }; 15 | 16 | const initialize = async (config = process.env) => { 17 | client = new Client({ connectionString: config.DATABASE_URL, ssl: true }); 18 | await client.connect(); 19 | 20 | await client.query( 21 | "CREATE TABLE IF NOT EXISTS brain (key TEXT PRIMARY KEY, value TEXT)", 22 | ); 23 | 24 | const data = await client.query("SELECT * FROM brain"); 25 | 26 | data.rows.forEach(({ key, value }) => { 27 | brain.set(key, JSON.parse(value)); 28 | }); 29 | }; 30 | 31 | module.exports = { initialize, get, set }; 32 | -------------------------------------------------------------------------------- /src/brain.test.js: -------------------------------------------------------------------------------- 1 | const { Client } = require("pg"); 2 | 3 | jest.mock("pg", () => ({ 4 | Client: jest.fn(), 5 | })); 6 | 7 | const brain = require("./brain"); 8 | 9 | describe("the brain", () => { 10 | const connect = jest.fn(); 11 | const query = jest.fn(); 12 | 13 | Client.mockImplementation(() => ({ connect, query })); 14 | 15 | beforeEach(() => { 16 | connect.mockReset(); 17 | query.mockReset(); 18 | }); 19 | 20 | it("initializes", async () => { 21 | query 22 | .mockResolvedValueOnce(true) 23 | .mockResolvedValueOnce({ rows: [{ key: "key", value: '"value"' }] }); 24 | 25 | await brain.initialize(); 26 | 27 | // One for creating the table, if necessary; one for reading existing data 28 | expect(query.mock.calls.length).toBe(2); 29 | expect(query.mock.calls[0][0]).toEqual( 30 | "CREATE TABLE IF NOT EXISTS brain (key TEXT PRIMARY KEY, value TEXT)", 31 | ); 32 | expect(query).toHaveBeenCalledWith("SELECT * FROM brain"); 33 | expect(brain.get("key")).toEqual("value"); 34 | }); 35 | 36 | it("gets values it knows about", () => { 37 | // This is set when the brain is initialized in the previous test. 38 | expect(brain.get("key")).toEqual("value"); 39 | }); 40 | 41 | it("gracefully handles values it doesn't know about", () => { 42 | expect(brain.get("value")).toEqual(undefined); 43 | }); 44 | 45 | it("sets and saves values", () => { 46 | brain.set("new key", "new value"); 47 | 48 | expect(brain.get("new key")).toEqual("new value"); 49 | expect(query).toHaveBeenCalledWith( 50 | "INSERT INTO brain (key, value) VALUES($1, $2) ON CONFLICT (key) DO UPDATE SET value=$2", 51 | ["new key", '"new value"'], 52 | ); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/env.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | if (process.env.VCAP_SERVICES) { 4 | const cfEnv = JSON.parse(process.env.VCAP_SERVICES); 5 | if (Array.isArray(cfEnv["user-provided"])) { 6 | const config = cfEnv["user-provided"].find( 7 | ({ name }) => name === "charlie-config", 8 | ); 9 | 10 | if (config) { 11 | Object.entries(config.credentials).forEach(([key, value]) => { 12 | if (!process.env[key]) { 13 | process.env[key] = value; 14 | } 15 | }); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/env.test.js: -------------------------------------------------------------------------------- 1 | describe("environment configurator", () => { 2 | /* eslint-disable global-require */ 3 | // Because the env.js module does all of its work at load-time, we need to 4 | // load it when it's time to execute the tests. And since we have more than 5 | // one test, we also need to unload and reload it. As a result, we don't have 6 | // our require() calls only at the top of the file. 7 | 8 | let config; 9 | 10 | beforeEach(() => { 11 | // Blow away the Node module cache so they'll reload when we require() them 12 | // again, rather than using their cached instances. 13 | jest.resetModules(); 14 | 15 | // Mock the dotenv.config method. We don't actually want it to do anything, 16 | // just make sure that it's called. 17 | config = require("dotenv").config; 18 | jest.mock("dotenv", () => ({ 19 | config: jest.fn(), 20 | })); 21 | 22 | // Also blank out the environment variables so we know what we've got. 23 | process.env = {}; 24 | }); 25 | 26 | it("configurates the environment without VCAP services", () => { 27 | require("./env"); 28 | 29 | expect(config).toHaveBeenCalled(); 30 | }); 31 | 32 | it("configurates the environment if there are VCAP services but no charlie config", () => { 33 | process.env.VCAP_SERVICES = JSON.stringify({ 34 | "user-provided": [ 35 | { name: "ignore these", credentials: { ohno: "should not exist" } }, 36 | ], 37 | }); 38 | 39 | require("./env"); 40 | 41 | expect(config).toHaveBeenCalled(); 42 | expect(process.env.ohno).toBeUndefined(); 43 | }); 44 | 45 | it("configurates the environment with VCAP services", () => { 46 | process.env.VCAP_SERVICES = JSON.stringify({ 47 | "user-provided": [ 48 | { name: "ignore these", credentials: { ohno: "should not exist" } }, 49 | { name: "charlie-config", credentials: { ohyay: "should exist" } }, 50 | ], 51 | }); 52 | 53 | require("./env"); 54 | 55 | expect(config).toHaveBeenCalled(); 56 | expect(process.env.ohno).toBeUndefined(); 57 | expect(process.env.ohyay).toEqual("should exist"); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/include.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require,import/no-dynamic-require */ 2 | 3 | const fs = require("fs").promises; 4 | const path = require("path"); 5 | 6 | describe("pull in everything, so we get good coverage", () => { 7 | it(`will always pass, but that's okay`, async () => { 8 | require("./brain"); 9 | require("./env"); 10 | 11 | const dirs = ["scripts", "utils"]; 12 | 13 | await Promise.all( 14 | dirs.map(async (dir) => { 15 | const allFiles = await fs.readdir(path.join(__dirname, dir)); 16 | const sourceFiles = allFiles.filter( 17 | (f) => f.endsWith(".js") && !f.endsWith(".test.js"), 18 | ); 19 | sourceFiles.forEach((f) => { 20 | require(`./${dir}/${f}`); 21 | }); 22 | }), 23 | ); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | require("./env"); 2 | const { App, LogLevel } = require("@slack/bolt"); 3 | const fs = require("fs").promises; 4 | const path = require("path"); 5 | const brain = require("./brain"); 6 | const { setClient } = require("./utils/slack"); 7 | 8 | const app = new App({ 9 | token: process.env.SLACK_TOKEN, 10 | signingSecret: process.env.SLACK_SIGNING_SECRET, 11 | logLevel: LogLevel[process.env.LOG_LEVEL] || LogLevel.INFO, 12 | }); 13 | 14 | app.logger.setName("18F Charlie bot"); 15 | 16 | const brainPromise = brain.initialize(); 17 | app.brain = brain; 18 | 19 | const port = process.env.PORT || 3000; 20 | app.start(port).then(async () => { 21 | app.logger.info(`Bot started on ${port}`); 22 | setClient(app.client); 23 | 24 | // Wait for the brain to be ready before loading any scripts. 25 | await brainPromise; 26 | app.logger.info("Brain is ready"); 27 | 28 | const files = (await fs.readdir(path.join(__dirname, "scripts"))).filter( 29 | (file) => file.endsWith(".js") && !file.endsWith(".test.js"), 30 | ); 31 | 32 | files.forEach((file) => { 33 | const script = require(`./scripts/${file}`); // eslint-disable-line global-require,import/no-dynamic-require 34 | if (typeof script === "function") { 35 | app.logger.info(`Loading bot script from: ${file}`); 36 | script(app); 37 | } 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/scripts/a11y.js: -------------------------------------------------------------------------------- 1 | const { 2 | helpMessage, 3 | stats: { incrementStats }, 4 | } = require("../utils"); 5 | 6 | module.exports = (app) => { 7 | helpMessage.registerInteractive( 8 | "A11y bot", 9 | "ask a11y", 10 | "Ready to learn more about accessibility? A11y bot is here! Get a list of commands that A11y bot can respond to.", 11 | ); 12 | 13 | app.message(/ask a(11|ll)y+$/i, async ({ say }) => { 14 | say({ 15 | blocks: [ 16 | { 17 | type: "section", 18 | text: { 19 | type: "mrkdwn", 20 | text: "*Here are some things you can type in Slack that A11yBot can respond to*", 21 | }, 22 | }, 23 | { 24 | type: "section", 25 | text: { 26 | type: "mrkdwn", 27 | text: "*ask a11y fact*", 28 | }, 29 | }, 30 | { 31 | type: "section", 32 | text: { 33 | type: "mrkdwn", 34 | text: "_This will return a random accessibility resource or fact_", 35 | }, 36 | }, 37 | { 38 | type: "section", 39 | text: { 40 | type: "mrkdwn", 41 | text: "*ask a11y*", 42 | }, 43 | }, 44 | { 45 | type: "section", 46 | text: { 47 | type: "mrkdwn", 48 | text: "_Returns a list of commands that a11y can respond to_", 49 | }, 50 | }, 51 | ], 52 | }); 53 | 54 | incrementStats("ask a11y"); 55 | }); 56 | }; 57 | -------------------------------------------------------------------------------- /src/scripts/a11y.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | 3 | const a11y = require("./a11y"); 4 | 5 | describe("ask a11y", () => { 6 | const app = getApp(); 7 | 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | it("registers the message handlers", () => { 13 | a11y(app); 14 | expect(app.message).toHaveBeenCalledWith( 15 | /ask a(11|ll)y+$/i, 16 | expect.any(Function), 17 | ); 18 | }); 19 | 20 | it("returns content", () => { 21 | a11y(app); 22 | const handler = app.getHandler(0); 23 | const say = jest.fn(); 24 | 25 | handler({ say }); 26 | 27 | expect(say).toHaveBeenCalledWith({ 28 | blocks: [ 29 | { 30 | type: "section", 31 | text: { 32 | type: "mrkdwn", 33 | text: "*Here are some things you can type in Slack that A11yBot can respond to*", 34 | }, 35 | }, 36 | { 37 | type: "section", 38 | text: { 39 | type: "mrkdwn", 40 | text: "*ask a11y fact*", 41 | }, 42 | }, 43 | { 44 | type: "section", 45 | text: { 46 | type: "mrkdwn", 47 | text: "_This will return a random accessibility resource or fact_", 48 | }, 49 | }, 50 | { 51 | type: "section", 52 | text: { 53 | type: "mrkdwn", 54 | text: "*ask a11y*", 55 | }, 56 | }, 57 | { 58 | type: "section", 59 | text: { 60 | type: "mrkdwn", 61 | text: "_Returns a list of commands that a11y can respond to_", 62 | }, 63 | }, 64 | ], 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/scripts/angry-tock.js: -------------------------------------------------------------------------------- 1 | const holidays = require("@18f/us-federal-holidays"); 2 | const moment = require("moment-timezone"); 3 | const scheduler = require("node-schedule"); 4 | const { 5 | dates: { getCurrentWorkWeek }, 6 | slack: { sendDirectMessage }, 7 | tock: { get18FTockSlackUsers, get18FUsersWhoHaveNotTocked }, 8 | helpMessage, 9 | } = require("../utils"); 10 | 11 | module.exports = (app, config = process.env) => { 12 | helpMessage.registerNonInteractive( 13 | "Angry Tock", 14 | "On the first morning of the work week, Angry Tock will disappointedly remind Tock-able users who haven't Tocked yet. At the end of the day, it'll also let supervisors know about folks who still have not Tocked.", 15 | ); 16 | 17 | const TOCK_API_URL = config.TOCK_API; 18 | const TOCK_TOKEN = config.TOCK_TOKEN; 19 | 20 | const ANGRY_TOCK_TIMEZONE = config.ANGRY_TOCK_TIMEZONE || "America/New_York"; 21 | const ANGRY_TOCK_FIRST_ALERT = moment( 22 | config.ANGRY_TOCK_FIRST_TIME || "10:00", 23 | "HH:mm", 24 | ); 25 | const ANGRY_TOCK_SECOND_ALERT = moment( 26 | config.ANGRY_TOCK_SECOND_TIME || "16:00", 27 | "HH:mm", 28 | ); 29 | 30 | /** 31 | * "Shout" at users who have not yet Tocked. 32 | * @async 33 | * @param {Object} options 34 | * @param {Boolean} options.calm Whether this is Happy Tock or Angry Tock. Angry 35 | * Tock is not calm. Defaults to Angry Tock. 36 | */ 37 | const shout = async ({ calm = false } = {}) => { 38 | const message = { 39 | username: `${calm ? "Disappointed" : "Angry"} Tock`, 40 | icon_emoji: calm ? ":disappointed-tock:" : ":angrytock:", 41 | text: calm 42 | ? ":disappointed-tock: Please !" 43 | : ":angrytock: ! You gotta!", 44 | }; 45 | 46 | const tockSlackUsers = await get18FTockSlackUsers(); 47 | const usersWhoNeedToTock = await get18FUsersWhoHaveNotTocked( 48 | moment.tz(ANGRY_TOCK_TIMEZONE), 49 | ); 50 | const slackUsersWhoNeedToTock = tockSlackUsers.filter((tockUser) => 51 | usersWhoNeedToTock.some( 52 | (user) => user.email?.toLowerCase() === tockUser.email?.toLowerCase(), 53 | ), 54 | ); 55 | 56 | slackUsersWhoNeedToTock.forEach(({ slack_id: slackID }) => { 57 | sendDirectMessage(slackID, message); 58 | }); 59 | }; 60 | 61 | const getNextShoutingDay = () => { 62 | // The reporting time for the current work week would be the first day of 63 | // that working week. 64 | const reportTime = moment 65 | .tz(getCurrentWorkWeek()[0], ANGRY_TOCK_TIMEZONE) 66 | .hour(ANGRY_TOCK_SECOND_ALERT.hour()) 67 | .minute(ANGRY_TOCK_SECOND_ALERT.minute()) 68 | .second(0); 69 | 70 | // If we've already passed the report time for the current work week, 71 | // jump to the next Monday, and then scoot forward over any holidays. 72 | if (reportTime.isBefore(moment())) { 73 | reportTime.add(7, "days").day(1); 74 | while (holidays.isAHoliday(reportTime.toDate())) { 75 | reportTime.add(1, "day"); 76 | } 77 | } 78 | 79 | return reportTime; 80 | }; 81 | 82 | /** 83 | * Schedules the next time to shout at users. 84 | */ 85 | const scheduleNextShoutingMatch = () => { 86 | const now = moment(); 87 | const day = getNextShoutingDay(); 88 | 89 | const firstHour = ANGRY_TOCK_FIRST_ALERT.hour(); 90 | const firstMinute = ANGRY_TOCK_FIRST_ALERT.minute(); 91 | 92 | const firstTockShoutTime = day 93 | .clone() 94 | .hour(firstHour) 95 | .minute(firstMinute) 96 | .second(0); 97 | 98 | const secondTockShoutTime = day 99 | .clone() 100 | .hour(ANGRY_TOCK_SECOND_ALERT.hour()) 101 | .minute(ANGRY_TOCK_SECOND_ALERT.minute()) 102 | .second(0); 103 | 104 | // If today is the normal day for Angry Tock to shout... 105 | if (now.isBefore(firstTockShoutTime)) { 106 | // ...and Angry Tock should not have shouted at all yet, schedule a calm 107 | // shout. 108 | return scheduler.scheduleJob(firstTockShoutTime.toDate(), async () => { 109 | await shout({ calm: true }); 110 | setTimeout(() => scheduleNextShoutingMatch(), 1000); 111 | }); 112 | } 113 | 114 | if (now.isBefore(secondTockShoutTime)) { 115 | // ...and Angry Tock should have shouted once, schedule an un-calm shout. 116 | return scheduler.scheduleJob(secondTockShoutTime.toDate(), async () => { 117 | setTimeout(() => scheduleNextShoutingMatch(), 1000); 118 | await shout({ calm: false }); 119 | }); 120 | } 121 | 122 | // Schedule a calm shout for the next shouting day. 123 | day.hour(firstHour).minute(firstMinute).second(0); 124 | 125 | return scheduler.scheduleJob(day.toDate(), async () => { 126 | setTimeout(() => scheduleNextShoutingMatch(), 1000); 127 | await shout({ calm: true }); 128 | }); 129 | }; 130 | 131 | if (!TOCK_API_URL || !TOCK_TOKEN) { 132 | app.logger.warn( 133 | "AngryTock disabled: Tock API URL or access token is not set", 134 | ); 135 | return; 136 | } 137 | 138 | scheduleNextShoutingMatch(); 139 | }; 140 | -------------------------------------------------------------------------------- /src/scripts/bio-art.js: -------------------------------------------------------------------------------- 1 | const { 2 | cache, 3 | helpMessage, 4 | slack: { postFile, postMessage }, 5 | stats: { incrementStats }, 6 | } = require("../utils"); 7 | const sample = require("../utils/sample"); 8 | 9 | // The set of ontologies we want. Some of them could be questionable, like 10 | // human anatomy, so leave those out. Maybe after we review them all more 11 | // thoroughly, we can decide whether to add more! 12 | const permitted = new Set([ 13 | "fungi", 14 | "parasites", 15 | "animals", 16 | "arthropods", 17 | "bacteria", 18 | "cells and organelles", 19 | "plants", 20 | "viruses", 21 | ]); 22 | 23 | const get = (url) => 24 | // The BioArt API requires a browser user-agent, so put that in here. 25 | fetch(url, { 26 | headers: { 27 | "User-Agent": 28 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:132.0) Gecko/20100101 Firefox/132.0", 29 | }, 30 | }); 31 | 32 | const getJSON = (url) => get(url).then((r) => r.json()); 33 | 34 | const getPermittedOntologyIDs = () => 35 | // The list of ontologu IDs is unlikely to change very often, so cache it 36 | // for an hour. 37 | cache("bio-art ontology id", 60, async () => { 38 | const allOntologies = await getJSON( 39 | "https://bioart.niaid.nih.gov/api/ontologies?type=Bioart%20Category", 40 | ); 41 | 42 | // Filter down to just the keys that we've allowed, and then map down to 43 | // just the ontology IDs. That's all we need going forward. 44 | return allOntologies 45 | .filter(({ ontologyKey }) => permitted.has(ontologyKey.toLowerCase())) 46 | .map(({ ontologyId }) => ontologyId); 47 | }); 48 | 49 | const getEntities = async (ontologyIds) => 50 | // The list of entities might change more often than the list of ontology IDs, 51 | // so we can cache it for a little shorter. 52 | cache(`bio-art entities [${ontologyIds.join(",")}]`, 30, async () => { 53 | const url = new URL("https://bioart.niaid.nih.gov"); 54 | 55 | // The search string is part of the URL path, which is unusual. Anyway, it's 56 | // these fields and values. 57 | const search = [ 58 | "type:bioart", 59 | `license:"Public Domain"`, 60 | `ontologyid:((${ontologyIds.join(" OR ")}))`, 61 | ]; 62 | 63 | // Now put the whole path together. 64 | url.pathname = `api/search/${search.join(" AND ")}`; 65 | 66 | // And add a query parameter for the number of entities to fetch. There may 67 | // be more entities, but we'll deal with that later. 68 | url.searchParams.set("size", 100); 69 | 70 | // found is the total number of entities that are responsive to our search, 71 | // and hit (initialList) is the first batch of those matches. 72 | const { 73 | hits: { found, hit: initialList }, 74 | } = 75 | // Use the URL.href method so it properly escapes the path and search 76 | // parameters. This way we don't have to think about it. :) 77 | await getJSON(url.href); 78 | 79 | const entities = [...initialList]; 80 | 81 | // If the number of entities we've received is less than the total number of 82 | // entities that match our search, run the search again but add the "start" 83 | // query paramemter so we get the next batch. Repeat until we have all of 84 | // the responsive entities. 85 | while (entities.length < found) { 86 | url.searchParams.set("start", entities.length); 87 | const { 88 | hits: { hit: nextList }, 89 | } = await getJSON(url.href); // eslint-disable-line no-await-in-loop 90 | entities.push(...nextList); 91 | } 92 | 93 | // And finally, we only want the field data, so map down to just that. 94 | return entities.map(({ fields }) => fields); 95 | }); 96 | 97 | const getRandomEntity = async () => { 98 | const ontologyIds = await getPermittedOntologyIDs(); 99 | const entities = await getEntities(ontologyIds); 100 | 101 | const entity = sample(entities); 102 | 103 | // An entity can have multiple variations, each with multiple files. We'll 104 | // just grab the first variant. For a given varient, the list of files is a 105 | // string of the form: 106 | // 107 | // FORMAT:id|FORMAT:id,id,id|FORMAT:id 108 | // 109 | // Where FORMAT is an image format such as PNG and the ids are a list of file 110 | // IDs used to actually fetch the file. So we'll grab the list of PNG file IDs 111 | // and then take the last one, for simplicity's sake. 112 | const file = entity.filesinfo[0] 113 | .split("|") 114 | .find((s) => s.startsWith("PNG:")) 115 | .split(":") 116 | .pop() 117 | .split(",") 118 | .pop(); 119 | 120 | // Once we have the file ID, we can build up a URL to fetch it. 121 | const fileUrl = new URL( 122 | `https://bioart.niaid.nih.gov/api/bioarts/${entity.id[0]}/zip?file-ids=${file}`, 123 | ).href; 124 | 125 | // All we want from the entity is its title, creator, and download URL. 126 | return { 127 | title: entity.title.pop(), 128 | creator: entity.creator.pop(), 129 | fileUrl, 130 | }; 131 | }; 132 | 133 | module.exports = (app) => { 134 | helpMessage.registerInteractive( 135 | "Bio-Art", 136 | "bio-art", 137 | "Get a random piece of bio-art from our friends at the National Institutes of Health!", 138 | ); 139 | 140 | app.message(/bio(-| )?art/i, async (msg) => { 141 | incrementStats("bio-art"); 142 | const { channel, thread_ts: thread } = msg.message; 143 | 144 | try { 145 | // Get an entity 146 | const entity = await getRandomEntity(); 147 | // Get its image file as a buffer 148 | const file = await get(entity.fileUrl) 149 | .then((r) => r.arrayBuffer()) 150 | .then((a) => Buffer.from(a)); 151 | 152 | // Post that sucker to Slack. 153 | postFile({ 154 | channel_id: channel, 155 | thread_ts: thread, 156 | initial_comment: `${entity.title} (art by ${entity.creator})`, 157 | file_uploads: [ 158 | { 159 | file, 160 | filename: `${entity.title.toLowerCase()}.png`, 161 | alt_text: entity.title, 162 | }, 163 | ], 164 | }); 165 | } catch (e) { 166 | app.logger.error("bio-art error:"); 167 | app.logger.error(e); 168 | 169 | postMessage({ 170 | channel, 171 | thread_ts: thread, 172 | text: "There was a problem fetching BioArt.", 173 | }); 174 | } 175 | }); 176 | }; 177 | -------------------------------------------------------------------------------- /src/scripts/coffeemate.js: -------------------------------------------------------------------------------- 1 | const { 2 | helpMessage, 3 | homepage, 4 | slack: { addEmojiReaction, postEphemeralResponse, sendDirectMessage }, 5 | stats: { incrementStats }, 6 | } = require("../utils"); 7 | 8 | const brainKey = "coffeemate_queue"; 9 | 10 | const COFFEE_ACTION_ID = "coffee_me"; 11 | const UNCOFFEE_ACTION_ID = "un_coffee_me"; 12 | 13 | const baseResponse = { 14 | icon_emoji: ":coffee:", 15 | text: "You two have been paired up for coffee. The next step is to figure out a time that works for both of you. Enjoy! :coffee:", 16 | username: "Coffeemate", 17 | }; 18 | 19 | module.exports = (app) => { 20 | helpMessage.registerInteractive( 21 | "Coffeemate", 22 | "coffee me", 23 | "Coffeemate can help you schedule virtual coffees :coffee: with random teammates! The bot will add you to a queue of people looking for coffee and will match you with someone else in the queue. Have fun!", 24 | ); 25 | 26 | const addToCoffeeQueue = async (userId, message = false, scope = "") => { 27 | const key = `${brainKey}${scope}`; 28 | const queue = app.brain.get(key) || []; 29 | 30 | if (queue.includes(userId)) { 31 | // If the request to be added to the queue came from a Slack message, 32 | // let the user know they're already in the queue. This request can also 33 | // come from a homepage interaction, in which case we already display that 34 | // they're in the queue. 35 | if (message) { 36 | await postEphemeralResponse(message, { 37 | ...baseResponse, 38 | text: `You’re already in the${ 39 | scope ? ` ${scope}` : "" 40 | } queue. As soon as we find someone else to meet with, we’ll introduce you!`, 41 | }); 42 | } 43 | return; 44 | } 45 | 46 | queue.push(userId); 47 | app.brain.set(key, queue); 48 | 49 | // Now do we have a pair or not? 50 | if (queue.length < 2) { 51 | if (message) { 52 | await postEphemeralResponse(message, { 53 | ...baseResponse, 54 | text: `You’re in line for${ 55 | scope ? ` ${scope}` : "" 56 | } coffee! You’ll be introduced to the next person who wants to meet up.`, 57 | }); 58 | } 59 | } else { 60 | try { 61 | // pair them up 62 | if (message) { 63 | await postEphemeralResponse(message, { 64 | ...baseResponse, 65 | text: `You’ve been matched up for coffee with <@${queue[0]}>! `, 66 | }); 67 | } 68 | 69 | // Now start a 1:1 DM chat between the people in queue. 70 | await sendDirectMessage([...queue], baseResponse); 71 | } catch (e) { 72 | // We don't really have a good way of capturing errors. The log is noisy 73 | // so just writing there isn't necessarily helpful, but we'll go ahead 74 | // and do it. 75 | app.logger.error(e); 76 | } finally { 77 | // And always empty the queue, no matter what; otherwise, users could 78 | // get stuck and we'd have to go manually edit the database to fix it. 79 | queue.length = 0; 80 | app.brain.set(key, queue); 81 | } 82 | } 83 | }; 84 | 85 | homepage.registerInteractive((userId) => { 86 | const inQueue = app.brain.get(brainKey)?.includes(userId); 87 | 88 | if (inQueue) { 89 | return { 90 | type: "section", 91 | text: { 92 | type: "mrkdwn", 93 | text: `:coffee: You’re in the coffee queue! As soon as we find someone else to meet with, we’ll introduce you.`, 94 | }, 95 | accessory: { 96 | type: "button", 97 | text: { type: "plain_text", text: "Leave queue" }, 98 | action_id: UNCOFFEE_ACTION_ID, 99 | }, 100 | }; 101 | } 102 | 103 | return { 104 | type: "section", 105 | text: { 106 | type: "mrkdwn", 107 | text: ":coffee: Sign up for a virtual coffee", 108 | }, 109 | accessory: { 110 | type: "button", 111 | text: { type: "plain_text", text: "Coffee Me!" }, 112 | action_id: COFFEE_ACTION_ID, 113 | }, 114 | }; 115 | }); 116 | 117 | app.action( 118 | UNCOFFEE_ACTION_ID, 119 | async ({ 120 | ack, 121 | body: { 122 | user: { id: userId }, 123 | }, 124 | client, 125 | }) => { 126 | await ack(); 127 | 128 | const queue = app.brain.get(brainKey) || []; 129 | const index = queue.findIndex((id) => id === userId); 130 | 131 | if (index >= 0) { 132 | queue.splice(index, 1); 133 | app.brain.set(brainKey, queue); 134 | 135 | // Now that the user's queue status has changed, update the homepage. 136 | homepage.refresh(userId, client); 137 | } 138 | }, 139 | ); 140 | 141 | app.action( 142 | COFFEE_ACTION_ID, 143 | async ({ 144 | ack, 145 | body: { 146 | user: { id: userId }, 147 | }, 148 | client, 149 | }) => { 150 | await ack(); 151 | incrementStats("coffeemate homepage"); 152 | 153 | await addToCoffeeQueue(userId); 154 | 155 | // The user's queue status may have changed; update the homepage in case. 156 | homepage.refresh(userId, client); 157 | }, 158 | ); 159 | 160 | app.message(/\bcoffee me\b/i, async (message) => { 161 | const { 162 | context: { 163 | matches: [, scopeMatch], 164 | }, 165 | event: { user }, 166 | } = message; 167 | 168 | incrementStats("coffeemate message"); 169 | 170 | // Ignore Slackbot. It's not supposed to trigger this bot anyway but it 171 | // seems like it has done so before, and then it gets stuck in the queue and 172 | // the only way to fix it is to manually clear out the database. 173 | if (user === "USLACKBOT") { 174 | return; 175 | } 176 | 177 | const scope = (() => { 178 | const out = scopeMatch ? scopeMatch.trim().toLowerCase() : ""; 179 | return out === "please" ? "" : out; 180 | })(); 181 | 182 | await addEmojiReaction(message, "coffee"); 183 | 184 | await addToCoffeeQueue(user, message, scope); 185 | }); 186 | }; 187 | 188 | module.exports.COFFEE_ACTION_ID = COFFEE_ACTION_ID; 189 | module.exports.UNCOFFEE_ACTION_ID = UNCOFFEE_ACTION_ID; 190 | module.exports.BRAIN_KEY = brainKey; 191 | -------------------------------------------------------------------------------- /src/scripts/dad-joke.js: -------------------------------------------------------------------------------- 1 | const { directMention } = require("@slack/bolt"); 2 | const fs = require("node:fs/promises"); 3 | const path = require("node:path"); 4 | const { 5 | helpMessage, 6 | stats: { incrementStats }, 7 | } = require("../utils"); 8 | const sample = require("../utils/sample"); 9 | 10 | module.exports = async (app) => { 11 | helpMessage.registerInteractive( 12 | "Dad Jokes", 13 | "dad joke", 14 | "Fetches a joke from Fatherhood.gov. Charlie will first set up the joke, then it'll provide the punchline!", 15 | true, 16 | ); 17 | 18 | const jokes = JSON.parse( 19 | await fs.readFile(path.join(__dirname, "dad-joke.json"), { 20 | encoding: "utf-8", 21 | }), 22 | ); 23 | 24 | app.message( 25 | directMention, 26 | /dad joke/i, 27 | async ({ message: { thread_ts: thread }, say }) => { 28 | incrementStats("dad joke"); 29 | 30 | const joke = sample(jokes); 31 | if (joke) { 32 | say({ 33 | icon_emoji: ":dog-joke-setup:", 34 | text: joke.setup, 35 | thread_ts: thread, 36 | username: "Jed Bartlett", 37 | }); 38 | 39 | setTimeout(() => { 40 | say({ 41 | icon_emoji: ":dog-joke:", 42 | text: joke.punchline, 43 | thread_ts: thread, 44 | username: "Jed \u200bBartlett", 45 | }); 46 | }, 5000); 47 | } 48 | }, 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/scripts/dad-joke.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("node:fs/promises"); 2 | const { getApp } = require("../utils/test"); 3 | 4 | const script = require("./dad-joke"); 5 | 6 | jest.mock("fs"); 7 | 8 | describe("dad jokes (are the best worst)", () => { 9 | const app = getApp(); 10 | 11 | beforeAll(() => { 12 | jest.useFakeTimers(); 13 | fs.readFile = jest.fn(); 14 | }); 15 | 16 | beforeEach(() => { 17 | jest.resetAllMocks(); 18 | }); 19 | 20 | afterAll(() => { 21 | jest.useRealTimers(); 22 | }); 23 | 24 | it("subscribes to dad joke requests", async () => { 25 | fs.readFile.mockResolvedValue("[]"); 26 | 27 | await script(app); 28 | 29 | expect(app.message).toHaveBeenCalledWith( 30 | expect.any(Function), 31 | /dad joke/i, 32 | expect.any(Function), 33 | ); 34 | }); 35 | 36 | describe("response to joke requests", () => { 37 | let handler; 38 | beforeEach(async () => { 39 | fs.readFile.mockResolvedValue( 40 | JSON.stringify([ 41 | { setup: "joke setup here", punchline: "the funny part" }, 42 | ]), 43 | ); 44 | 45 | await script(app); 46 | handler = app.getHandler(); 47 | }); 48 | 49 | const message = { message: { thread_ts: "thread id" }, say: jest.fn() }; 50 | 51 | it("responds with a joke", async () => { 52 | await handler(message); 53 | 54 | expect(message.say).toHaveBeenCalledWith({ 55 | icon_emoji: ":dog-joke-setup:", 56 | text: "joke setup here", 57 | thread_ts: "thread id", 58 | username: "Jed Bartlett", 59 | }); 60 | 61 | // Ensure the punchline part comes after a delay by clearing out the 62 | // existing mock calls. 63 | message.say.mockClear(); 64 | jest.advanceTimersByTime(5000); 65 | 66 | expect(message.say).toHaveBeenCalledWith({ 67 | icon_emoji: ":dog-joke:", 68 | text: "the funny part", 69 | thread_ts: "thread id", 70 | username: "Jed \u200bBartlett", 71 | }); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/scripts/define.js: -------------------------------------------------------------------------------- 1 | // Description: 2 | // Ask for an explanation of an abbreviation/jargon 3 | // 4 | // Depdeendencies: 5 | // "js-yaml": "3.4.1" 6 | // 7 | // Commands: 8 | // @bot define - returns a defined term 9 | // 10 | // Examples: 11 | // @bot define ATO 12 | // @bot define contracting officer 13 | 14 | const { directMention } = require("@slack/bolt"); 15 | const he = require("he"); 16 | const yaml = require("js-yaml"); 17 | const { 18 | cache, 19 | helpMessage, 20 | stats: { incrementStats }, 21 | } = require("../utils"); 22 | 23 | /** 24 | * Turn a string into a search slug, removing all non-word characters (including spaces and punctuation). 25 | */ 26 | const slugify = (term) => term.replaceAll(/\W/gi, "").toLowerCase(); 27 | 28 | /** 29 | * Find a string in a list of strings, ignoring case. 30 | * @param list [Array] List of strings (the haystack) 31 | * @param searchTerm [String] The term to find (the needle) 32 | * @return [String | null] The canonical key for the found term 33 | */ 34 | const findCaseInsensitively = (list, searchTerm) => { 35 | const lowerSearch = slugify(searchTerm); 36 | for (let i = 0; i < list.length; i += 1) { 37 | const term = list[i]; 38 | if (slugify(term) === lowerSearch) { 39 | return term; 40 | } 41 | } 42 | return null; 43 | }; 44 | 45 | /** 46 | * Return the definition for a term 47 | * 48 | * @param key [String] The canonical key for the entry 49 | * @param entry [Object] The entry that may or may not have a definition. Should have `type: "term"`. 50 | * @return [String] The definition or, if no definition, the default message. 51 | * @todo Raise an error if entry doesn't have `type: "term"` 52 | */ 53 | const defineTerm = (key, entry) => { 54 | if (entry.description) { 55 | return `*${key}*: ${entry.description}`; 56 | } 57 | return `The term *${key}* is in the glossary, but does not have a definition. If you find out what it means, !`; 58 | }; 59 | 60 | /** 61 | * Given one or more terms, collect definitions. 62 | * Used to gather definitions of terms for an acronym. 63 | * 64 | * @param entry [Object | Array] The term or terms that will be defined. 65 | * @param glossary [Object] The entire glossary 66 | * @return [String] List of definitions (newline-separated) 67 | */ 68 | const collectDefinitions = (entry, glossary) => 69 | [entry.term] 70 | .flat() 71 | .map((termKey) => defineTerm(termKey, glossary[termKey])) 72 | .join("\n"); 73 | 74 | /** 75 | * The Slackbot response to be sent back to the user, based on whether 76 | * the entry is an acronym or term. 77 | * 78 | * @param searchTerm [String] The original term the user searched for 79 | * @param canonicalKey [String] The key from the glossary representing the term. 80 | * @param glossary [Object] The entire glossary 81 | * @return [String] Definition(s) for the given entry 82 | */ 83 | const buildResponseText = (searchTerm, canonicalKey, glossary) => { 84 | const entry = glossary[canonicalKey]; 85 | switch (entry.type) { 86 | case "acronym": 87 | return `_${canonicalKey}_ means:\n${collectDefinitions(entry, glossary)}`; 88 | case "term": 89 | return defineTerm(canonicalKey, entry); 90 | default: 91 | return "An unexpected error occurred."; 92 | } 93 | }; 94 | 95 | module.exports = (app) => { 96 | helpMessage.registerInteractive( 97 | "Glossary", 98 | "(define or glossary) ", 99 | "Not sure what something means? The TTS glossary might have something to help you, and Charlie gives you a shortcut to access the glossary directly from Slack.", 100 | true, 101 | ); 102 | 103 | app.message( 104 | directMention, 105 | /(define|glossary) (.+)/i, 106 | async ({ context, event: { thread_ts: thread }, say }) => { 107 | incrementStats("define/glossary"); 108 | 109 | // Grab this match from the context immediately. The context can change 110 | // when we give up the execution thread with an async call below, so we 111 | // need to grab it before we do that. 112 | const searchTerm = he.decode(context.matches[2].trim()); 113 | 114 | // Cache the glossary for 1 minute 115 | const glossary = await cache("glossary get", 60, async () => { 116 | const data = await fetch( 117 | "https://raw.githubusercontent.com/18F/the-glossary/main/glossary.yml", 118 | ).then((r) => r.text()); 119 | 120 | return yaml.load(data, { json: true }).entries; 121 | }); 122 | 123 | // Set up the Slack response 124 | const response = { 125 | icon_emoji: ":book:", 126 | thread_ts: thread, 127 | text: "", 128 | }; 129 | 130 | const terms = Object.keys(glossary); 131 | const maybeEntry = findCaseInsensitively(terms, searchTerm); 132 | 133 | // If the term was found, return a response. Otherwise, send the 'not found' message. 134 | if (maybeEntry) { 135 | response.text = buildResponseText(searchTerm, maybeEntry, glossary); 136 | } else { 137 | response.text = `I couldn't find *${searchTerm}*. Once you find out what it means, would you please ?`; 138 | } 139 | 140 | say(response); 141 | }, 142 | ); 143 | }; 144 | -------------------------------------------------------------------------------- /src/scripts/erg-inviter.js: -------------------------------------------------------------------------------- 1 | const { directMention } = require("@slack/bolt"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const yaml = require("js-yaml"); 5 | const { 6 | helpMessage, 7 | homepage: { registerInteractive }, 8 | slack: { postMessage, sendDirectMessage }, 9 | stats: { incrementStats }, 10 | } = require("../utils"); 11 | 12 | const requestERGInvitationActionId = "erg_invite_request"; 13 | const showGroupsModalActionId = "show_erg_modal"; 14 | 15 | const getERGs = () => { 16 | // Read in the list of ERGs from the Yaml file 17 | const ymlStr = fs.readFileSync(path.join(__dirname, "erg-inviter.yaml")); 18 | const { ergs } = yaml.load(ymlStr, { json: true }); 19 | 20 | return ergs; 21 | }; 22 | 23 | module.exports = async (app) => { 24 | helpMessage.registerInteractive( 25 | "ERG Inviter", 26 | "ergs", 27 | "Charlie can send you a list of TTS employee resource and affinity groups that accept automated invitation requests. This command will send you a private message listing the ERGs and a button for each one to let the group know you'd like an invitation.", 28 | true, 29 | ); 30 | 31 | const ergs = module.exports.getERGs(); 32 | 33 | const getButtons = () => 34 | Object.entries(ergs).map(([name, { channel, description }]) => ({ 35 | type: "section", 36 | text: { 37 | type: "mrkdwn", 38 | text: `• *${name}*: ${description}`, 39 | }, 40 | accessory: { 41 | type: "button", 42 | text: { type: "plain_text", text: `Request invitation to ${name}` }, 43 | value: channel, 44 | action_id: requestERGInvitationActionId, 45 | }, 46 | })); 47 | 48 | registerInteractive(() => ({ 49 | type: "section", 50 | text: { 51 | type: "mrkdwn", 52 | text: ":inclusion-bot: Request an invitation to TTS employee affinity group Slack channels:.", 53 | }, 54 | accessory: { 55 | type: "button", 56 | text: { type: "plain_text", text: "See a list of groups" }, 57 | action_id: showGroupsModalActionId, 58 | }, 59 | })); 60 | 61 | app.action( 62 | showGroupsModalActionId, 63 | async ({ ack, body: { trigger_id: trigger }, client }) => { 64 | await ack(); 65 | incrementStats("list ERGs from home page"); 66 | 67 | client.views.open({ 68 | trigger_id: trigger, 69 | view: { 70 | type: "modal", 71 | title: { type: "plain_text", text: "TTS affinity groups" }, 72 | blocks: getButtons(), 73 | }, 74 | }); 75 | }, 76 | ); 77 | 78 | app.action( 79 | requestERGInvitationActionId, 80 | async ({ 81 | action: { value: channel }, 82 | ack, 83 | body: { 84 | user: { id: userId }, 85 | }, 86 | }) => { 87 | await ack(); 88 | incrementStats("request ERG invitation"); 89 | 90 | postMessage({ 91 | channel, 92 | icon_emoji: ":tts:", 93 | text: `:wave: <@${userId}> has requested an invitation to this channel.`, 94 | username: "Inclusion Bot", 95 | }); 96 | 97 | sendDirectMessage(userId, { 98 | icon_emoji: ":tts:", 99 | text: "Okay, I've sent your request to join that channel.", 100 | username: "Inclusion Bot", 101 | }); 102 | }, 103 | ); 104 | 105 | app.message(directMention, /ergs/i, ({ event: { user } }) => { 106 | incrementStats("list ERGs from message"); 107 | 108 | sendDirectMessage(user, { 109 | icon_emoji: ":tts:", 110 | text: "Here are the available employee afinity group channels.", 111 | username: "Inclusion Bot", 112 | blocks: [ 113 | { 114 | type: "section", 115 | text: { 116 | type: "mrkdwn", 117 | text: "Here are the available employee afinity group channels.", 118 | }, 119 | }, 120 | ...getButtons(), 121 | ], 122 | }); 123 | }); 124 | }; 125 | 126 | module.exports.REQUEST_ERG_INVITATION_ACTION_ID = requestERGInvitationActionId; 127 | module.exports.SHOW_ERG_MODAL_ACTION_ID = showGroupsModalActionId; 128 | module.exports.getERGs = getERGs; 129 | -------------------------------------------------------------------------------- /src/scripts/erg-inviter.yaml: -------------------------------------------------------------------------------- 1 | ergs: 2 | Latinx: 3 | description: > 4 | A space for people who identify as Latinx, Hispanic, Chicanx, Boricua, 5 | Cubano, etc. 6 | channel: "#Latinx" 7 | 8 | Asian and Pacific Islanders: 9 | description: > 10 | A place for Asian and Pacific Islander TTSers to hang out and talk. 11 | channel: "#not-that-kind-of-API" 12 | 13 | Shalom Jews: 14 | description: > 15 | A space for TTS Jews to schmooze. There is no gatekeeping of this channel. 16 | Feel free to come join no matter what your connection to Judaism -- if you 17 | consider yourself Jewish, Jew-ish, Jew-curious, culturally Jewish, or 18 | anything else! 19 | channel: "#shalom-jews" 20 | 21 | People with Disabilities (PwD): 22 | description: > 23 | Private channel for anyone who identifies themselves as disabled. You may 24 | share about your disability/disabilities at the level of your comfort, but 25 | you don't have to disclose to join. 26 | channel: "#tts-pwd" 27 | 28 | Neurodivergents: 29 | description: > 30 | A place for neurodivergent folks to share and discuss our lived 31 | experiences in community. This channel also serves as a point of contact 32 | for the newly-created Neurodivergent Employee Resource Group. 33 | channel: "#neurodivergents" 34 | 35 | Womxn: 36 | description: > 37 | A safe place for womxn to share their experiences and support one another. 38 | Specifically, this group was created to focus on issues that womxn face in 39 | the workplace. This ERG is a space for womxn to discuss strategies for 40 | career development, address the challenges we encounter in the workplace, 41 | and help one another in and around TTS. 42 | channel: "#womxn" 43 | 44 | Not Your Dude: 45 | description: > 46 | Private channel for cis women, trans women, trans men, non-binary people, 47 | and those who are otherwise marginalized (i.e., non-hegemonic masculine 48 | folks). 49 | channel: "#womxn-private" -------------------------------------------------------------------------------- /src/scripts/erg-inviter.yaml.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const yaml = require("js-yaml"); 4 | 5 | describe("ERG inviter config file", () => { 6 | expect.extend({ 7 | isValidErgDefinition: (erg, index) => { 8 | let pass = true; 9 | let message = ""; 10 | 11 | if (typeof erg !== "object") { 12 | message = `Item ${index} is not an object`; 13 | pass = false; 14 | } else if (!erg.description || typeof erg.description !== "string") { 15 | message = `Description of ERG ${index} is a ${typeof erg.description}, but should be a string`; 16 | pass = false; 17 | } else if (!erg.channel || typeof erg.channel !== "string") { 18 | message = `Channel of ERG ${index} is a ${typeof erg.channel}, but should be a stirng`; 19 | pass = false; 20 | } else if (!erg.channel.startsWith("#")) { 21 | message = `Channel of ERG ${index} does not start with a # symbol`; 22 | pass = false; 23 | } 24 | 25 | return { 26 | message: () => message, 27 | pass, 28 | }; 29 | }, 30 | }); 31 | 32 | const ymlStr = fs.readFileSync( 33 | path.join( 34 | path.dirname(require.resolve("./erg-inviter")), 35 | "erg-inviter.yaml", 36 | ), 37 | ); 38 | const yml = yaml.load(ymlStr, { json: true }); 39 | 40 | it("starts with a top-level ergs property", () => { 41 | expect(Object.keys(yml).length).toEqual(1); 42 | expect(typeof yml.ergs).toEqual("object"); 43 | }); 44 | 45 | it("each item is a valid ERG object", () => { 46 | const { ergs } = yml; 47 | Object.values(ergs).forEach((erg, i) => { 48 | expect(erg).isValidErgDefinition(i); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/scripts/evermarch.js: -------------------------------------------------------------------------------- 1 | /* This is a joke bot, in reference to how time never seems to pass since March 2 | 2020. This is one of our coping mechanisms during the COVID-19 pandemic. 3 | Future maintainers, please be gentle with us. We're doing the best we can 4 | with what we've got. 5 | */ 6 | 7 | const moment = require("moment-timezone"); 8 | 9 | const march1 = moment.tz("2020-03-01T00:00:00", "America/New_York"); 10 | 11 | module.exports = (robot) => { 12 | robot.message(/evermarch/i, ({ message, say }) => { 13 | const now = moment.tz("America/New_York"); 14 | 15 | const days = now.diff(march1, "days") + 1; 16 | 17 | say({ 18 | icon_emoji: ":calendar-this-is-fine:", 19 | text: `Today is March ${days}, 2020, in the Evermarch reckoning.`, 20 | thread_ts: message.thread_ts, 21 | }); 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/scripts/evermarch.test.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const { getApp } = require("../utils/test"); 3 | const evermarch = require("./evermarch"); 4 | 5 | describe("The Evermarch", () => { 6 | const app = getApp(); 7 | 8 | beforeAll(() => { 9 | jest.useFakeTimers(); 10 | }); 11 | 12 | beforeEach(() => { 13 | jest.setSystemTime(0); 14 | jest.resetAllMocks(); 15 | }); 16 | 17 | afterAll(() => { 18 | jest.useRealTimers(); 19 | }); 20 | 21 | it('subscribes to "evermarch" messages', () => { 22 | evermarch(app); 23 | expect(app.message).toHaveBeenCalledWith( 24 | /evermarch/i, 25 | expect.any(Function), 26 | ); 27 | }); 28 | 29 | it("is correct starting March 2, 2020", () => { 30 | jest.setSystemTime( 31 | moment.tz("2020-03-02T00:00:00", "America/New_York").toDate(), 32 | ); 33 | 34 | const message = { 35 | message: { thread_ts: "thread timestamp" }, 36 | say: jest.fn(), 37 | }; 38 | 39 | evermarch(app); 40 | const handler = app.getHandler(); 41 | handler(message); 42 | 43 | expect(message.say).toHaveBeenCalledWith({ 44 | icon_emoji: ":calendar-this-is-fine:", 45 | text: "Today is March 2, 2020, in the Evermarch reckoning.", 46 | thread_ts: "thread timestamp", 47 | }); 48 | }); 49 | 50 | it("is correct at the very end of March 2, 2020", () => { 51 | jest.setSystemTime( 52 | moment.tz("2020-03-02T23:59:59", "America/New_York").toDate(), 53 | ); 54 | 55 | const message = { 56 | message: { thread_ts: "thread timestamp" }, 57 | say: jest.fn(), 58 | }; 59 | 60 | evermarch(app); 61 | const handler = app.getHandler(); 62 | handler(message); 63 | 64 | expect(message.say).toHaveBeenCalledWith({ 65 | icon_emoji: ":calendar-this-is-fine:", 66 | text: "Today is March 2, 2020, in the Evermarch reckoning.", 67 | thread_ts: "thread timestamp", 68 | }); 69 | }); 70 | 71 | it("is correct at the very start of March 3, 2020", () => { 72 | jest.setSystemTime( 73 | moment.tz("2020-03-03T00:00:00", "America/New_York").toDate(), 74 | ); 75 | 76 | const message = { 77 | message: { thread_ts: "thread timestamp" }, 78 | say: jest.fn(), 79 | }; 80 | 81 | evermarch(app); 82 | const handler = app.getHandler(); 83 | handler(message); 84 | 85 | expect(message.say).toHaveBeenCalledWith({ 86 | icon_emoji: ":calendar-this-is-fine:", 87 | text: "Today is March 3, 2020, in the Evermarch reckoning.", 88 | thread_ts: "thread timestamp", 89 | }); 90 | }); 91 | 92 | it("is correct in the further future from March 1, 2020", () => { 93 | jest.setSystemTime( 94 | moment.tz("2024-10-15T01:00:00", "America/New_York").toDate(), 95 | ); 96 | 97 | const message = { 98 | message: { thread_ts: "thread timestamp" }, 99 | say: jest.fn(), 100 | }; 101 | 102 | evermarch(app); 103 | const handler = app.getHandler(); 104 | handler(message); 105 | 106 | expect(message.say).toHaveBeenCalledWith({ 107 | icon_emoji: ":calendar-this-is-fine:", 108 | text: "Today is March 1690, 2020, in the Evermarch reckoning.", 109 | thread_ts: "thread timestamp", 110 | }); 111 | }); 112 | 113 | it("is gets to March 2020, 2020, on the expected date", () => { 114 | jest.setSystemTime( 115 | moment.tz("2025-09-10T00:00:00", "America/New_York").toDate(), 116 | ); 117 | 118 | const message = { 119 | message: { thread_ts: "thread timestamp" }, 120 | say: jest.fn(), 121 | }; 122 | 123 | evermarch(app); 124 | const handler = app.getHandler(); 125 | handler(message); 126 | 127 | expect(message.say).toHaveBeenCalledWith({ 128 | icon_emoji: ":calendar-this-is-fine:", 129 | text: "Today is March 2020, 2020, in the Evermarch reckoning.", 130 | thread_ts: "thread timestamp", 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/scripts/fancy-font.js: -------------------------------------------------------------------------------- 1 | const { 2 | helpMessage, 3 | stats: { incrementStats }, 4 | } = require("../utils"); 5 | 6 | const fancy = { 7 | a: "𝓪", 8 | b: "𝓫", 9 | c: "𝓬", 10 | d: "𝓭", 11 | e: "𝓮", 12 | f: "𝓯", 13 | g: "𝓰", 14 | h: "𝓱", 15 | i: "𝓲", 16 | j: "𝓳", 17 | k: "𝓴", 18 | l: "𝓵", 19 | m: "𝓶", 20 | n: "𝓷", 21 | o: "𝓸", 22 | p: "𝓹", 23 | q: "𝓺", 24 | r: "𝓻", 25 | s: "𝓼", 26 | t: "𝓽", 27 | u: "𝓾", 28 | v: "𝓿", 29 | w: "𝔀", 30 | x: "𝔁", 31 | y: "𝔂", 32 | z: "𝔃", 33 | A: "𝓐", 34 | B: "𝓑", 35 | C: "𝓒", 36 | D: "𝓓", 37 | E: "𝓔", 38 | F: "𝓕", 39 | G: "𝓖", 40 | H: "𝓗", 41 | I: "𝓘", 42 | J: "𝓙", 43 | K: "𝓚", 44 | L: "𝓛", 45 | M: "𝓜", 46 | N: "𝓝", 47 | O: "𝓞", 48 | P: "𝓟", 49 | Q: "𝓠", 50 | R: "𝓡", 51 | S: "𝓢", 52 | T: "𝓣", 53 | U: "𝓤", 54 | V: "𝓥", 55 | W: "𝓦", 56 | X: "𝓧", 57 | Y: "𝓨", 58 | Z: "𝓩", 59 | "!": "!", 60 | }; 61 | 62 | module.exports = (app) => { 63 | helpMessage.registerInteractive( 64 | "Fancy Font", 65 | "fancy font ", 66 | "Feeling fancy, and want your message to reflect that? Charlie is here to fancify your words!", 67 | ); 68 | 69 | app.message(/^fancy font (.*)$/i, ({ context, message, say }) => { 70 | incrementStats("fancy font"); 71 | 72 | const plain = context.matches[1]; 73 | const out = plain 74 | .split("") 75 | .map((c) => fancy[c] || c) 76 | .join(""); 77 | 78 | say({ 79 | icon_emoji: ":fancy-capybara:", 80 | text: out, 81 | thread_ts: message.thread_ts, 82 | username: "Fancy Charlie", 83 | }); 84 | }); 85 | }; 86 | -------------------------------------------------------------------------------- /src/scripts/fancy-font.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | const fancyFont = require("./fancy-font"); 3 | 4 | describe("fancy-font", () => { 5 | const app = getApp(); 6 | 7 | beforeEach(() => { 8 | jest.resetAllMocks(); 9 | }); 10 | 11 | it('subscribes to "fancy font" messages', () => { 12 | fancyFont(app); 13 | expect(app.message).toHaveBeenCalledWith( 14 | /^fancy font (.*)$/i, 15 | expect.any(Function), 16 | ); 17 | }); 18 | 19 | it("converts ASCII Latin characters to fancy font", () => { 20 | const message = { 21 | context: { 22 | matches: [ 23 | "fancy font whole message", 24 | "ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz", 25 | ], 26 | }, 27 | message: { thread_ts: "thread timestamp" }, 28 | say: jest.fn(), 29 | }; 30 | 31 | fancyFont(app); 32 | const handler = app.getHandler(); 33 | handler(message); 34 | 35 | expect(message.say).toHaveBeenCalledWith({ 36 | icon_emoji: ":fancy-capybara:", 37 | text: "𝓐𝓑𝓒𝓓𝓔𝓕𝓖𝓗𝓘𝓙𝓚𝓛𝓜𝓝𝓞𝓟𝓠𝓡𝓢𝓣𝓤𝓥𝓦𝓧𝓨𝓩 𝓪𝓫𝓬𝓭𝓮𝓯𝓰𝓱𝓲𝓳𝓴𝓵𝓶𝓷𝓸𝓹𝓺𝓻𝓼𝓽𝓾𝓿𝔀𝔁𝔂𝔃", 38 | thread_ts: "thread timestamp", 39 | username: "Fancy Charlie", 40 | }); 41 | }); 42 | 43 | it("hands back non-ASCII Latin characters without changing them", () => { 44 | const message = { 45 | context: { matches: ["fancy font ABC abc å∫ç∂´ƒ©", "ABC abc å∫ç∂´ƒ©"] }, 46 | message: { thread_ts: "another thread" }, 47 | say: jest.fn(), 48 | }; 49 | 50 | fancyFont(app); 51 | const handler = app.getHandler(); 52 | handler(message); 53 | 54 | expect(message.say).toHaveBeenCalledWith({ 55 | icon_emoji: ":fancy-capybara:", 56 | text: "𝓐𝓑𝓒 𝓪𝓫𝓬 å∫ç∂´ƒ©", 57 | thread_ts: "another thread", 58 | username: "Fancy Charlie", 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/scripts/federal-holidays-reminder.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const scheduler = require("node-schedule"); 3 | const { 4 | dates: { getNextHoliday }, 5 | holidays: { emojis }, 6 | slack: { postMessage }, 7 | helpMessage, 8 | } = require("../utils"); 9 | const sample = require("../utils/sample"); 10 | 11 | const workLessMessages = [ 12 | "Only do 32 hours worth of work since there are only 32 hours to do them in!", 13 | "This is your permission to cancel some meetings and only do 32 hours of work for the holiday week!", 14 | "Don't try to fit 40 hours of work into the holiday week 32-hour week!", 15 | "Observe it the way that is most appropriate to you, and claim that 8 hours for yourself.", 16 | "Work at your normal pace for the week and only do 32 hours worth!", 17 | "Time is not compressed; it's just gone! So you can also get rid of 8 hours worth of work.", 18 | "32 high-quality work hours are preferable to 40 hours worth of exhausted work crammed into 32 hours!", 19 | ]; 20 | 21 | // The first argument is always the bot object. We don't actually need it for 22 | // this script, so capture and toss it out. 23 | const scheduleReminder = (_, config = process.env) => { 24 | const CHANNEL = config.HOLIDAY_REMINDER_CHANNEL || "general"; 25 | const TIMEZONE = config.HOLIDAY_REMINDER_TIMEZONE || "America/New_York"; 26 | const reportingTime = moment( 27 | config.HOLIDAY_REMINDER_TIME || "15:00", 28 | "HH:mm", 29 | ); 30 | 31 | helpMessage.registerNonInteractive( 32 | "Holiday reminders", 33 | `On the business day before a federal holiday, Charlie will post a reminder in #${CHANNEL}. Take the day off, don't do work for the government, and observe it in the way that is most suitable to you!`, 34 | ); 35 | 36 | const previousWeekday = (date) => { 37 | const source = moment(date); 38 | source.subtract(1, "day"); 39 | 40 | let dow = source.format("dddd"); 41 | while (dow === "Saturday" || dow === "Sunday") { 42 | source.subtract(1, "day"); 43 | dow = source.format("dddd"); 44 | } 45 | 46 | return source; 47 | }; 48 | 49 | const postReminder = async (holiday) => { 50 | const emoji = emojis.get(holiday.name); 51 | 52 | await postMessage({ 53 | channel: CHANNEL, 54 | text: `Remember that *${holiday.date.format( 55 | "dddd", 56 | )}* is a federal holiday in observance of *${ 57 | holiday.alsoObservedAs ?? holiday.name 58 | }*${emoji ? ` ${emoji}` : ""}! ${sample(workLessMessages)}`, 59 | }); 60 | }; 61 | 62 | const nextHoliday = getNextHoliday(TIMEZONE); 63 | const target = previousWeekday(nextHoliday.date); 64 | 65 | target.hour(reportingTime.hour()); 66 | target.minute(reportingTime.minute()); 67 | 68 | scheduler.scheduleJob(target.toDate(), async () => { 69 | await postReminder(nextHoliday); 70 | 71 | // Tomorrow, schedule the next holiday reminder 72 | scheduler.scheduleJob(target.add(1, "day").toDate(), () => { 73 | scheduleReminder(); 74 | }); 75 | }); 76 | }; 77 | 78 | module.exports = scheduleReminder; 79 | -------------------------------------------------------------------------------- /src/scripts/federal-holidays-reminder.test.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | 3 | const { 4 | utils: { 5 | dates: { getNextHoliday }, 6 | slack: { postMessage }, 7 | }, 8 | } = require("../utils/test"); 9 | 10 | describe("holiday reminder", () => { 11 | const scheduleJob = jest.fn(); 12 | jest.doMock("node-schedule", () => ({ scheduleJob })); 13 | 14 | // Load this module *after* everything gets mocked. Otherwise the module will 15 | // load the unmocked stuff and the tests won't work. 16 | // eslint-disable-next-line global-require 17 | const bot = require("./federal-holidays-reminder"); 18 | 19 | beforeEach(() => { 20 | jest.resetAllMocks(); 21 | }); 22 | 23 | describe("schedules a reminder on startup", () => { 24 | describe("reminds on Friday for holidays on Monday", () => { 25 | it("defaults to 15:00", () => { 26 | const nextHoliday = { 27 | date: moment.tz("2021-08-16T12:00:00", "America/New_York"), 28 | }; 29 | getNextHoliday.mockReturnValue(nextHoliday); 30 | 31 | bot(null, {}); 32 | expect(scheduleJob).toHaveBeenCalledWith( 33 | moment(nextHoliday.date).subtract(3, "days").hour(15).toDate(), 34 | expect.any(Function), 35 | ); 36 | }); 37 | 38 | it("respects HOLIDAY_REMINDER_TIME", () => { 39 | const nextHoliday = { 40 | date: moment.tz("2021-08-16T12:00:00", "America/New_York"), 41 | }; 42 | getNextHoliday.mockReturnValue(nextHoliday); 43 | 44 | bot(null, { HOLIDAY_REMINDER_TIME: "04:32" }); 45 | expect(scheduleJob).toHaveBeenCalledWith( 46 | moment(nextHoliday.date) 47 | .subtract(3, "days") 48 | .hour(4) 49 | .minute(32) 50 | .toDate(), 51 | expect.any(Function), 52 | ); 53 | }); 54 | }); 55 | 56 | describe("reminds on Wednesday for holidays on Thursday", () => { 57 | it("defaults to 15:00", () => { 58 | const nextHoliday = { date: moment("2021-08-19T12:00:00Z") }; 59 | getNextHoliday.mockReturnValue(nextHoliday); 60 | 61 | bot(null); 62 | expect(scheduleJob).toHaveBeenCalledWith( 63 | moment(nextHoliday.date).subtract(1, "day").hour(15).toDate(), 64 | expect.any(Function), 65 | ); 66 | }); 67 | 68 | it("respects HOLIDAY_REMINDER_TIME", () => { 69 | const nextHoliday = { date: moment("2021-08-19T12:00:00Z") }; 70 | getNextHoliday.mockReturnValue(nextHoliday); 71 | 72 | bot(null, { HOLIDAY_REMINDER_TIME: "04:32" }); 73 | expect(scheduleJob).toHaveBeenCalledWith( 74 | moment(nextHoliday.date) 75 | .subtract(1, "day") 76 | .hour(4) 77 | .minute(32) 78 | .toDate(), 79 | expect.any(Function), 80 | ); 81 | }); 82 | }); 83 | }); 84 | 85 | describe("posts a reminder", () => { 86 | const date = moment.tz("2021-08-19T12:00:00", "America/New_York"); 87 | 88 | const getReminderFn = (holiday, config = {}) => { 89 | const nextHoliday = { 90 | date, 91 | name: holiday, 92 | }; 93 | getNextHoliday.mockReturnValue(nextHoliday); 94 | 95 | bot(null, config); 96 | 97 | return scheduleJob.mock.calls[0][1]; 98 | }; 99 | 100 | it("defaults to general channel", async () => { 101 | const postReminder = getReminderFn("test holiday"); 102 | await postReminder(); 103 | 104 | expect(postMessage).toHaveBeenCalledWith({ 105 | channel: "general", 106 | text: expect.stringMatching( 107 | /^Remember that \*Thursday\* is a federal holiday in observance of \*test holiday\*! .+$/, 108 | ), 109 | }); 110 | 111 | // Sets up a job for tomorrow to schedule the next reminder. Because the 112 | // scheduled job above runs the day before the holiday, this upcoming job 113 | // (1 day later) will be ON the holiday, at 15:00. This logic is the same 114 | // for the subsequent tests below. 115 | expect(scheduleJob).toHaveBeenCalledWith( 116 | moment(date).hour(15).toDate(), 117 | expect.any(Function), 118 | ); 119 | }); 120 | 121 | it("respects HOLIDAY_REMINDER_CHANNEL", async () => { 122 | const postReminder = getReminderFn("test holiday", { 123 | HOLIDAY_REMINDER_CHANNEL: "test channel", 124 | }); 125 | await postReminder(); 126 | 127 | expect(postMessage).toHaveBeenCalledWith({ 128 | channel: "test channel", 129 | text: expect.stringMatching( 130 | /^Remember that \*Thursday\* is a federal holiday in observance of \*test holiday\*! .+$/, 131 | ), 132 | }); 133 | 134 | // Sets up a job for tomorrow to schedule the next reminder 135 | expect(scheduleJob).toHaveBeenCalledWith( 136 | moment(date).hour(15).toDate(), 137 | expect.any(Function), 138 | ); 139 | }); 140 | 141 | it("includes an emoji for holidays with known emoji", async () => { 142 | const postReminder = getReminderFn("Veterans Day"); 143 | await postReminder(); 144 | 145 | expect(postMessage).toHaveBeenCalledWith({ 146 | channel: "general", 147 | text: expect.stringMatching( 148 | /^Remember that \*Thursday\* is a federal holiday in observance of \*Veterans Day\* :salute-you:! .+$/, 149 | ), 150 | }); 151 | 152 | // Sets up a job for tomorrow to schedule the next reminder 153 | expect(scheduleJob).toHaveBeenCalledWith( 154 | moment(date).hour(15).toDate(), 155 | expect.any(Function), 156 | ); 157 | }); 158 | 159 | it("waits a day and then schedules the next holiday", async () => { 160 | const postReminder = getReminderFn("test holiday"); 161 | await postReminder(); 162 | 163 | const nextSchedule = scheduleJob.mock.calls[1][1]; 164 | 165 | const nextHoliday = { date: moment("2021-09-01T12:00:00Z") }; 166 | getNextHoliday.mockReturnValue(nextHoliday); 167 | 168 | nextSchedule(); 169 | expect(scheduleJob).toHaveBeenCalledWith( 170 | moment(nextHoliday.date).subtract(1, "day").hour(15).toDate(), 171 | expect.any(Function), 172 | ); 173 | }); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /src/scripts/federal-holidays.js: -------------------------------------------------------------------------------- 1 | const { directMention } = require("@slack/bolt"); 2 | const moment = require("moment"); 3 | const { 4 | dates: { getNextHoliday }, 5 | helpMessage, 6 | holidays: { emojis }, 7 | homepage: { registerDidYouKnow }, 8 | stats: { incrementStats }, 9 | } = require("../utils"); 10 | 11 | const getHolidayText = () => { 12 | const holiday = getNextHoliday(); 13 | const nextOne = moment(holiday.date); 14 | const daysUntil = Math.ceil( 15 | moment.duration(nextOne.utc().format("x") - Date.now()).asDays(), 16 | ); 17 | 18 | const emoji = emojis.get(holiday.alsoObservedAs ?? holiday.name); 19 | 20 | return `The next federal holiday is ${ 21 | holiday.alsoObservedAs ?? holiday.name 22 | } ${emoji || ""}${emoji ? " " : ""}in ${daysUntil} days on ${nextOne 23 | .utc() 24 | .format("dddd, MMMM Do")}`; 25 | }; 26 | 27 | module.exports = (app) => { 28 | helpMessage.registerInteractive( 29 | "Federal holidays", 30 | "next holiday", 31 | "Itching for a day off and want to know when the next holiday is? Charlie knows all the (standard, recurring) federal holidays and will gladly tell you what's coming up next!", 32 | true, 33 | ); 34 | 35 | registerDidYouKnow(() => ({ 36 | type: "section", 37 | text: { 38 | type: "mrkdwn", 39 | text: getHolidayText(), 40 | }, 41 | })); 42 | 43 | app.message( 44 | directMention, 45 | /next (federal )?holiday/i, 46 | ({ event: { thread_ts: thread }, say }) => { 47 | say({ text: getHolidayText(), thread_ts: thread }); 48 | incrementStats("next federal holiday request"); 49 | }, 50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /src/scripts/federal-holidays.test.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const { getApp } = require("../utils/test"); 3 | const bot = require("./federal-holidays"); 4 | const { 5 | utils: { 6 | dates: { getNextHoliday }, 7 | homepage: { registerDidYouKnow }, 8 | }, 9 | } = require("../utils/test"); 10 | 11 | describe("federal holidays bot", () => { 12 | const app = getApp(); 13 | 14 | beforeAll(() => { 15 | jest.useFakeTimers(); 16 | }); 17 | 18 | beforeEach(() => { 19 | jest.setSystemTime(0); 20 | jest.resetAllMocks(); 21 | }); 22 | 23 | afterAll(() => { 24 | jest.useRealTimers(); 25 | }); 26 | 27 | it("registers a responder for federal holidays", () => { 28 | bot(app); 29 | 30 | expect(app.message).toHaveBeenCalledWith( 31 | expect.any(Function), 32 | expect.any(RegExp), 33 | expect.any(Function), 34 | ); 35 | }); 36 | 37 | it("the registration regex matches appropriately", () => { 38 | bot(app); 39 | const regex = app.message.mock.calls[0][1]; 40 | [ 41 | "next holiday", 42 | "next federal holiday", 43 | "when is the next holiday", 44 | "when's the next federal holiday", 45 | ].forEach((trigger) => { 46 | expect(regex.test(trigger)).toBe(true); 47 | }); 48 | }); 49 | 50 | it("registers did-you-know content for Charlie's homepage", () => { 51 | bot(app); 52 | 53 | expect(registerDidYouKnow).toHaveBeenCalledWith(expect.any(Function)); 54 | }); 55 | 56 | it("sends did-you-know content when requested", () => { 57 | bot(app); 58 | 59 | getNextHoliday.mockReturnValue({ 60 | date: moment.tz("1970-01-02T00:00:00", "UTC"), 61 | name: "Test Holiday day", 62 | }); 63 | 64 | const content = registerDidYouKnow.mock.calls[0][0](); 65 | 66 | expect(content).toEqual({ 67 | type: "section", 68 | text: { 69 | type: "mrkdwn", 70 | text: "The next federal holiday is Test Holiday day in 1 days on Friday, January 2nd", 71 | }, 72 | }); 73 | }); 74 | 75 | describe("responds to a request for the next federal holiday", () => { 76 | it("uses the official holiday name if there is not an alternate name", () => { 77 | bot(app); 78 | const handler = app.getHandler(); 79 | const say = jest.fn(); 80 | 81 | getNextHoliday.mockReturnValue({ 82 | date: moment.tz("1970-01-02T00:00:00", "UTC"), 83 | name: "Test Holiday day", 84 | }); 85 | 86 | handler({ event: {}, say }); 87 | 88 | expect(say.mock.calls.length).toBe(1); 89 | expect(say).toHaveBeenCalledWith({ 90 | text: "The next federal holiday is Test Holiday day in 1 days on Friday, January 2nd", 91 | thread_ts: undefined, 92 | }); 93 | }); 94 | 95 | it("uses an alternate name, if provided", () => { 96 | bot(app); 97 | const handler = app.getHandler(); 98 | const say = jest.fn(); 99 | 100 | getNextHoliday.mockReturnValue({ 101 | date: moment.tz("1970-01-02T00:00:00", "UTC"), 102 | name: "Test Holiday day", 103 | alsoObservedAs: "Other holiday day day day", 104 | }); 105 | 106 | handler({ event: { thread_ts: "thread" }, say }); 107 | 108 | expect(say.mock.calls.length).toBe(1); 109 | expect(say).toHaveBeenCalledWith({ 110 | text: "The next federal holiday is Other holiday day day day in 1 days on Friday, January 2nd", 111 | thread_ts: "thread", 112 | }); 113 | }); 114 | }); 115 | 116 | describe("includes an emoji for well-known holidays", () => { 117 | it("posts the emoji if the holiday has an emoji", () => { 118 | bot(app); 119 | const handler = app.getHandler(); 120 | const say = jest.fn(); 121 | 122 | getNextHoliday.mockReturnValue({ 123 | date: moment.tz("1970-01-02T00:00:00", "UTC"), 124 | name: "Christmas Day", 125 | }); 126 | 127 | handler({ event: {}, say }); 128 | 129 | expect(say.mock.calls.length).toBe(1); 130 | expect(say).toHaveBeenCalledWith({ 131 | text: "The next federal holiday is Christmas Day :christmas_tree: in 1 days on Friday, January 2nd", 132 | thread_ts: undefined, 133 | }); 134 | }); 135 | 136 | it("does not include the emoji if the holiday does not have one", () => { 137 | bot(app); 138 | const handler = app.getHandler(); 139 | const say = jest.fn(); 140 | 141 | getNextHoliday.mockReturnValue({ 142 | date: moment.tz("1970-01-02T00:00:00", "UTC"), 143 | name: "Columbus Day", 144 | alsoObservedAs: "Indigenous Peoples' Day", 145 | }); 146 | 147 | handler({ event: {}, say }); 148 | 149 | expect(say.mock.calls.length).toBe(1); 150 | expect(say).toHaveBeenCalledWith({ 151 | text: "The next federal holiday is Indigenous Peoples' Day in 1 days on Friday, January 2nd", 152 | thread_ts: undefined, 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/scripts/handbook.js: -------------------------------------------------------------------------------- 1 | const { 2 | helpMessage, 3 | slack: { postEphemeralResponse }, 4 | stats: { incrementStats }, 5 | } = require("../utils"); 6 | 7 | const baseUrl = 8 | "https://search.usa.gov/search/?utf8=no&affiliate=tts-handbook&format=json&query="; 9 | 10 | const identity = { icon_emoji: ":tts:", username: "TTS Handbook" }; 11 | 12 | const getBlocksFromResults = (results) => 13 | results.reduce((blocks, result) => { 14 | blocks.push({ type: "divider" }); 15 | blocks.push({ 16 | type: "section", 17 | text: { 18 | type: "mrkdwn", 19 | text: `<${result.link}|${result.title.replace( 20 | /[^ -~]+/g, 21 | "", 22 | )}>\n${result.body.replace(/[^ -~]+/g, "")}`, 23 | }, 24 | }); 25 | blocks.push({ 26 | type: "context", 27 | elements: [{ type: "mrkdwn", text: result.link }], 28 | }); 29 | return blocks; 30 | }, []); 31 | 32 | module.exports = (app) => { 33 | helpMessage.registerInteractive( 34 | "Handbook search", 35 | "@handbook ", 36 | "Wondering if the TTS Handbook has useful information but don't want to open your browser? Charlie can search for you! If it finds anything, it'll post links in a threaded response.", 37 | ); 38 | 39 | app.message(/^@?handbook (.+)$/i, async (msg) => { 40 | incrementStats("handbook search"); 41 | 42 | const { 43 | context: { 44 | matches: [, search], 45 | }, 46 | event: { thread_ts: thread, ts }, 47 | say, 48 | } = msg; 49 | 50 | console.log(msg.context.matches); 51 | 52 | const searchString = search 53 | ?.replace(/[”“]/g, '"') // replace smart quotes 54 | ?.replace(/[’‘]/g, "'"); // more smart quotes 55 | const url = `${baseUrl}${encodeURIComponent(searchString)}`; 56 | 57 | try { 58 | const data = await fetch(url).then((r) => r.json()); 59 | const results = data.results.slice(0, 3); 60 | 61 | if (results.length === 0) { 62 | say({ 63 | ...identity, 64 | thread_ts: thread || ts, 65 | text: `I couldn't find any results for "${searchString}"`, 66 | }); 67 | } else { 68 | say({ 69 | ...identity, 70 | thread_ts: thread || ts, 71 | blocks: [ 72 | { 73 | type: "header", 74 | text: { 75 | type: "plain_text", 76 | text: `Handbook search results for "${searchString}"`, 77 | }, 78 | }, 79 | ...getBlocksFromResults(results), 80 | ], 81 | }); 82 | } 83 | } catch (e) { 84 | postEphemeralResponse(msg, { 85 | ...identity, 86 | text: "Something went wrong trying to search the Handbook. Please try later!", 87 | }); 88 | } 89 | }); 90 | }; 91 | -------------------------------------------------------------------------------- /src/scripts/help.js: -------------------------------------------------------------------------------- 1 | const { directMention } = require("@slack/bolt"); 2 | const { 3 | helpMessage: { 4 | getHelp, 5 | type: { interactive, noninteractive }, 6 | }, 7 | slack: { getSlackUsers }, 8 | stats: { incrementStats }, 9 | } = require("../utils"); 10 | 11 | module.exports = async (app) => { 12 | incrementStats("help"); 13 | 14 | let botName = false; 15 | 16 | app.message( 17 | directMention, 18 | "help", 19 | async ({ 20 | context: { botUserId }, 21 | event: { thread_ts: thread, ts }, 22 | say, 23 | }) => { 24 | if (botName === false) { 25 | botName = (await getSlackUsers()).find( 26 | (u) => u.id === botUserId, 27 | ).real_name; 28 | } 29 | 30 | const modules = getHelp(); 31 | 32 | const interactiveBots = [...modules.get(interactive)].sort((a, b) => 33 | a.name.localeCompare(b.name), 34 | ); 35 | const noninteractiveBots = [...modules.get(noninteractive)].sort((a, b) => 36 | a.name.localeCompare(b.name), 37 | ); 38 | 39 | const blocks = [ 40 | { 41 | type: "header", 42 | text: { type: "plain_text", text: "Interactive bots" }, 43 | }, 44 | ]; 45 | 46 | for (const bot of interactiveBots) { 47 | blocks.push( 48 | { 49 | type: "section", 50 | text: { 51 | type: "mrkdwn", 52 | text: `*${bot.name}*: ${bot.helpText}${ 53 | bot.directMention ? " (requires @-mentioning Charlie)" : "" 54 | }\n\`\`\`${bot.directMention ? `@${botName} ` : ""}${ 55 | bot.trigger 56 | }\`\`\``, 57 | }, 58 | }, 59 | { type: "divider" }, 60 | ); 61 | } 62 | // get rid of the last divider 63 | blocks.pop(); 64 | 65 | blocks.push({ 66 | type: "header", 67 | text: { type: "plain_text", text: "Non-interactive bots" }, 68 | }); 69 | 70 | for (const bot of noninteractiveBots) { 71 | blocks.push( 72 | { 73 | type: "section", 74 | text: { type: "mrkdwn", text: `*${bot.name}*: ${bot.helpText}` }, 75 | }, 76 | { type: "divider" }, 77 | ); 78 | } 79 | // get rid of the last divider 80 | blocks.pop(); 81 | 82 | const messages = []; 83 | 84 | // The Slack API only allows up to 50 blocks per message. If we have more 85 | // than 50 blocks, then, we need to break them up into multiple messages. 86 | do { 87 | const subset = blocks.splice(0, 50); 88 | 89 | // After removing the subset, if the first remaining block is a divider, 90 | // throw it out. We don't need to start messages with a divider. 91 | if (blocks.length > 0 && blocks[0].type === "divider") { 92 | blocks.shift(); 93 | } 94 | 95 | // If the first or last block of the subset is a divider, toss it. 96 | if (subset[0].type === "divider") { 97 | subset.shift(); 98 | } 99 | if (subset[subset.length - 1].type === "divider") { 100 | subset.pop(); 101 | } 102 | 103 | // Add this subset to the list of messages to send. 104 | messages.push({ 105 | blocks: subset, 106 | text: "Charlie help", 107 | thread_ts: thread ?? ts, 108 | }); 109 | 110 | // Because the subset is spliced from the original blocks, we can just 111 | // keep iterating until there aren't any blocks left. 112 | } while (blocks.length > 0); 113 | 114 | // Send all the messages 115 | for await (const message of messages) { 116 | await say(message); 117 | } 118 | }, 119 | ); 120 | }; 121 | -------------------------------------------------------------------------------- /src/scripts/home.js: -------------------------------------------------------------------------------- 1 | const { 2 | homepage: { getDidYouKnow, getInteractive, registerRefresh }, 3 | optOut: { BRAIN_KEY: OPT_OUT_BRAIN_KEY, options: optOutOptions }, 4 | } = require("../utils"); 5 | 6 | const sleep = async (ms) => 7 | new Promise((resolve) => { 8 | setTimeout(() => { 9 | resolve(); 10 | }, ms); 11 | }); 12 | 13 | module.exports = async (app) => { 14 | const publishView = async (userId, client) => { 15 | // An item is enabled if it is NOT opted out of. The brain stores opt-out 16 | // information, not opt-in. 17 | const optedOut = app.brain.get(OPT_OUT_BRAIN_KEY) || {}; 18 | for (const o of optOutOptions) { 19 | o.enabled = !optedOut[o.key]?.includes(userId); 20 | } 21 | 22 | const makeOptionFromOptout = (optout) => ({ 23 | text: { type: "plain_text", text: optout.name }, 24 | description: { type: "plain_text", text: optout.description }, 25 | value: optout.key, 26 | }); 27 | 28 | // https://charlie-dev.app.cloud.gov 29 | 30 | const view = { 31 | user_id: userId, 32 | view: { 33 | type: "home", 34 | blocks: [ 35 | { 36 | type: "header", 37 | text: { 38 | type: "plain_text", 39 | text: "Did you know?", 40 | }, 41 | }, 42 | ...getDidYouKnow(userId), 43 | { type: "divider" }, 44 | { 45 | type: "header", 46 | text: { 47 | type: "plain_text", 48 | text: "Interactions", 49 | }, 50 | }, 51 | ...getInteractive(userId), 52 | { type: "divider" }, 53 | { 54 | type: "header", 55 | text: { 56 | type: "plain_text", 57 | text: "Personalized Charlie options", 58 | }, 59 | }, 60 | { 61 | type: "actions", 62 | elements: [ 63 | { 64 | type: "checkboxes", 65 | initial_options: optOutOptions 66 | .filter(({ enabled }) => enabled) 67 | .map(makeOptionFromOptout), 68 | options: optOutOptions.map(makeOptionFromOptout), 69 | action_id: "set_options", 70 | }, 71 | ], 72 | }, 73 | ], 74 | }, 75 | }; 76 | 77 | // initial_options cannot be an empty array for REASONS. So if there is one, 78 | // just delete it entirely. Also toss it out if it's not an array. Let's 79 | // just go ahead and be safe. 80 | for (const block of view.view.blocks || []) { 81 | for (const element of block.elements || []) { 82 | if (element.initial_options) { 83 | if ( 84 | !Array.isArray(element.initial_options) || 85 | element.initial_options.length === 0 86 | ) { 87 | delete element.initial_options; 88 | } 89 | } 90 | } 91 | } 92 | 93 | client.views.publish(view); 94 | }; 95 | 96 | registerRefresh(publishView); 97 | 98 | // Wait a couple seconds for all the other scripts to run, so they can 99 | // register themselves with the opt-out handler. We'll use that to build up 100 | // the list of options for the home page. Ideally we'd be able to get some 101 | // kind of event when everything was done, but that doesn't exist and would 102 | // take a boatload of plumbing to hook up. Alternatively, this file could be 103 | // renamed in such a way that it gets loaded last since the scripts are run in 104 | // alphabetical order, but I don't think that's guaranteed so we probably 105 | // shouldn't rely on it. But you know what? This little delay is good enough 106 | // for now, and if it's ever not good enough, we can deal with that then. 107 | await sleep(2000); 108 | 109 | app.action( 110 | "set_options", 111 | async ({ 112 | ack, 113 | action: { selected_options: options }, 114 | body: { 115 | user: { id: userId }, 116 | }, 117 | }) => { 118 | await ack(); 119 | 120 | // While reading this code, here's an important tip that will make it less 121 | // confusing: the brain stores who is OPTED OUT, but the value we receive 122 | // in this action lists items to be OPTED INTO. So there's some reversing 123 | // that has to happen to map those together. 124 | 125 | const optIn = options.map(({ value }) => value); 126 | let dirty = false; 127 | 128 | const optedOut = app.brain.get(OPT_OUT_BRAIN_KEY) || {}; 129 | for (const key of Object.keys(optedOut)) { 130 | if (optIn.includes(key) && optedOut[key].includes(userId)) { 131 | const index = optedOut[key].indexOf(userId); 132 | optedOut[key].splice(index, 1); 133 | dirty = true; 134 | } else if (!optIn.includes(key) && !optedOut[key].includes(userId)) { 135 | optedOut[key].push(userId); 136 | dirty = true; 137 | } 138 | } 139 | if (dirty) { 140 | await app.brain.set(OPT_OUT_BRAIN_KEY, optedOut); 141 | } 142 | }, 143 | ); 144 | 145 | app.event("app_home_opened", ({ event, client }) => 146 | publishView(event.user, client), 147 | ); 148 | }; 149 | -------------------------------------------------------------------------------- /src/scripts/i-voted.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const scheduler = require("node-schedule"); 3 | const { 4 | dates: { getNextElectionDay }, 5 | helpMessage, 6 | homepage: { registerDidYouKnow }, 7 | slack: { postMessage }, 8 | } = require("../utils"); 9 | 10 | const postElectionDayReminder = async (config = process.env) => { 11 | const CHANNEL = config.VOTING_REMINDER_CHANNEL || "general-talk"; 12 | 13 | await postMessage({ 14 | channel: CHANNEL, 15 | text: `It's :vote-gov: Election Day! Want to celebrate voting, the cornerstone of democracy? Drop by #i-voted, tell us about your voting experience, and share pictures of your stickers! Is your sticker even better than :justice-schooner: Justice Schooner? Let's see it!`, 16 | }); 17 | }; 18 | 19 | const scheduleReminder = (config = process.env) => { 20 | const REPORT_TIME = moment(config.VOTING_REMINDER_TIME || "10:00", "HH:mm"); 21 | 22 | // For our target time, we use US Eastern time. If we do a straight-up 23 | // timezone conversion, we'll actually go backwards a day because the date 24 | // utility returns dates at midnight UTC. So we do this silly dance to get the 25 | // date in eastern timezone instead. 26 | const targetTime = moment.tz( 27 | getNextElectionDay().format("YYYY-MM-DD"), 28 | "America/New_York", 29 | ); 30 | 31 | // Then adjust it to the correct time. 32 | targetTime.hour(REPORT_TIME.hour()); 33 | targetTime.minute(REPORT_TIME.minute()); 34 | 35 | scheduler.scheduleJob(targetTime.toDate(), async () => { 36 | postElectionDayReminder(config); 37 | setTimeout( 38 | () => { 39 | scheduleReminder(config); 40 | }, 41 | 48 * 60 * 60 * 1000, 42 | ); 43 | }); 44 | }; 45 | 46 | module.exports = (_, config = process.env) => { 47 | const CHANNEL = config.VOTING_REMINDER_CHANNEL || "general-talk"; 48 | 49 | helpMessage.registerNonInteractive( 50 | "Election Day", 51 | `On Election Day in the United States, Charlie will post a reminder in #${CHANNEL} to celebrate the cornerstorn of democracy in #i-voted!`, 52 | ); 53 | 54 | registerDidYouKnow(() => { 55 | const nextElectionDay = getNextElectionDay(); 56 | 57 | return { 58 | type: "section", 59 | text: { 60 | type: "mrkdwn", 61 | text: `The next federal :vote-gov: Election Day is ${nextElectionDay.format( 62 | "MMMM Do, YYYY", 63 | )}`, 64 | }, 65 | }; 66 | }); 67 | 68 | scheduleReminder(config); 69 | }; 70 | -------------------------------------------------------------------------------- /src/scripts/i-voted.test.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const { 3 | utils: { 4 | dates: { getNextElectionDay }, 5 | helpMessage, 6 | homepage: { registerDidYouKnow }, 7 | slack: { postMessage }, 8 | }, 9 | } = require("../utils/test"); 10 | 11 | describe("Election Day reminder bot", () => { 12 | const scheduleJob = jest.fn(); 13 | jest.doMock("node-schedule", () => ({ scheduleJob })); 14 | 15 | // Load this module *after* everything gets mocked. Otherwise the module will 16 | // load the unmocked stuff and the tests won't work. 17 | // eslint-disable-next-line global-require 18 | const bot = require("./i-voted"); 19 | 20 | beforeAll(() => { 21 | jest.useFakeTimers(); 22 | }); 23 | 24 | beforeEach(() => { 25 | jest.setSystemTime(0); 26 | jest.resetAllMocks(); 27 | 28 | // The initialization relies on the next Election Day date, so we'll just 29 | // go ahead and mock one in for now. 30 | getNextElectionDay.mockReturnValue(moment.utc("1973-04-04", "YYYY-MM-DD")); 31 | }); 32 | 33 | afterAll(() => { 34 | jest.useRealTimers(); 35 | }); 36 | 37 | describe("registers a help message", () => { 38 | it("defaults to #general-talk", () => { 39 | bot(); 40 | 41 | expect(helpMessage.registerNonInteractive).toHaveBeenCalledWith( 42 | "Election Day", 43 | expect.stringMatching(/#general-talk/), 44 | ); 45 | }); 46 | 47 | it("or uses a configured channel", () => { 48 | bot(null, { VOTING_REMINDER_CHANNEL: "bob" }); 49 | 50 | expect(helpMessage.registerNonInteractive).toHaveBeenCalledWith( 51 | "Election Day", 52 | expect.stringMatching(/#bob/), 53 | ); 54 | }); 55 | }); 56 | 57 | describe("registers a did-you-know message", () => { 58 | it("registers the callback", () => { 59 | bot(); 60 | expect(registerDidYouKnow).toHaveBeenCalledWith(expect.any(Function)); 61 | }); 62 | 63 | it("the callback returns the expected message", () => { 64 | bot(); 65 | const callback = registerDidYouKnow.mock.calls[0][0]; 66 | 67 | const message = callback(); 68 | 69 | expect(message).toEqual({ 70 | type: "section", 71 | text: { 72 | type: "mrkdwn", 73 | text: "The next federal :vote-gov: Election Day is April 4th, 1973", 74 | }, 75 | }); 76 | }); 77 | }); 78 | 79 | describe("schedules a reminder message", () => { 80 | it("defaults to 10:00 am eastern time", () => { 81 | bot(); 82 | 83 | expect(scheduleJob).toHaveBeenCalledWith( 84 | moment.tz("1973-04-04T10:00:00", "America/New_York").toDate(), 85 | expect.any(Function), 86 | ); 87 | }); 88 | 89 | it("honors a configured reporting time", () => { 90 | bot(null, { VOTING_REMINDER_TIME: "16:00" }); 91 | 92 | expect(scheduleJob).toHaveBeenCalledWith( 93 | moment.tz("1973-04-04T16:00:00", "America/New_York").toDate(), 94 | expect.any(Function), 95 | ); 96 | }); 97 | 98 | it("posts a message as its callback", () => { 99 | bot(null); 100 | 101 | const callback = scheduleJob.mock.calls[0][1]; 102 | callback(); 103 | 104 | expect(postMessage).toHaveBeenCalledWith({ 105 | channel: "general-talk", 106 | text: "It's :vote-gov: Election Day! Want to celebrate voting, the cornerstone of democracy? Drop by #i-voted, tell us about your voting experience, and share pictures of your stickers! Is your sticker even better than :justice-schooner: Justice Schooner? Let's see it!", 107 | }); 108 | }); 109 | 110 | it("schedules the next reminder as part of the callback", () => { 111 | bot(null, { VOTING_REMINDER_TIME: "16:00" }); 112 | 113 | const callback = scheduleJob.mock.calls[0][1]; 114 | 115 | jest.resetAllMocks(); 116 | callback(); 117 | 118 | getNextElectionDay.mockReturnValue( 119 | moment.utc("1988-11-09", "YYYY-MM-DD"), 120 | ); 121 | 122 | jest.runAllTimers(); 123 | 124 | expect(scheduleJob).toHaveBeenCalledWith( 125 | moment.tz("1988-11-09T10:00:00", "America/New_York").toDate(), 126 | expect.any(Function), 127 | ); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/scripts/inclusion-bot.yaml.test.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const yaml = require("js-yaml"); 4 | 5 | describe("Inclusion bot config file", () => { 6 | expect.extend({ 7 | isArrayOfStrings: (array, triggerIndex, type) => { 8 | const badIndex = array.findIndex((v) => typeof v !== "string"); 9 | if (badIndex >= 0) { 10 | return { 11 | message: () => 12 | `item ${triggerIndex}, ${type} ${badIndex} is a ${typeof array[ 13 | badIndex 14 | ]}, but should be a string`, 15 | pass: false, 16 | }; 17 | } 18 | 19 | return { message: () => {}, pass: true }; 20 | }, 21 | 22 | isString: (value, index) => { 23 | if (typeof value !== "string") { 24 | return { 25 | message: () => 26 | `item ${index} is a ${typeof value}, but should be a string`, 27 | pass: false, 28 | }; 29 | } 30 | 31 | return { message: () => {}, pass: true }; 32 | }, 33 | 34 | isValidTrigger: (trigger, index) => { 35 | if (typeof trigger !== "object") { 36 | return { 37 | message: () => 38 | `item ${index} is a ${typeof trigger}, but should be an object`, 39 | pass: false, 40 | }; 41 | } 42 | 43 | const keys = Object.keys(trigger); 44 | 45 | if (keys.indexOf("matches") < 0) { 46 | return { 47 | message: () => `item ${index} does not have matches`, 48 | pass: false, 49 | }; 50 | } 51 | 52 | if (!Array.isArray(trigger.matches)) { 53 | return { 54 | message: () => `item ${index} matches is not an array`, 55 | pass: false, 56 | }; 57 | } 58 | 59 | if (keys.indexOf("alternatives") < 0) { 60 | return { 61 | message: () => `item ${index} does not have alternatives`, 62 | pass: false, 63 | }; 64 | } 65 | 66 | if (!Array.isArray(trigger.alternatives)) { 67 | return { 68 | message: () => `item ${index} alternatives is not an array`, 69 | pass: false, 70 | }; 71 | } 72 | 73 | if (keys.indexOf("ignore") >= 0 && !Array.isArray(trigger.ignore)) { 74 | return { 75 | message: () => `item ${index} ignore is not an array`, 76 | pass: false, 77 | }; 78 | } 79 | 80 | if (keys.indexOf("why") >= 0 && typeof trigger.why !== "string") { 81 | return { 82 | message: () => `item ${index} why is not a string`, 83 | pass: false, 84 | }; 85 | } 86 | 87 | const validKeys = ["matches", "alternatives", "ignore", "why"]; 88 | const invalidKeys = keys.filter((key) => !validKeys.includes(key)); 89 | 90 | if (invalidKeys.length > 0) { 91 | return { 92 | message: () => 93 | `item ${index} has invalid keys: ${invalidKeys.join(", ")}`, 94 | pass: false, 95 | }; 96 | } 97 | 98 | return { 99 | message: () => {}, 100 | pass: true, 101 | }; 102 | }, 103 | }); 104 | 105 | const ymlStr = fs.readFileSync( 106 | path.join( 107 | path.dirname(require.resolve("./inclusion-bot")), 108 | "inclusion-bot.yaml", 109 | ), 110 | ); 111 | const yml = yaml.load(ymlStr, { json: true }); 112 | 113 | it("starts with a top-level triggers property", () => { 114 | expect(Object.keys(yml).length).toEqual(3); 115 | expect(typeof yml.link).toEqual("string"); 116 | expect(typeof yml.message).toEqual("string"); 117 | expect(Array.isArray(yml.triggers)).toEqual(true); 118 | }); 119 | 120 | it("each item is an object, and each property of each object is a string", () => { 121 | const { triggers } = yml; 122 | triggers.forEach((trigger, i) => { 123 | expect(trigger).isValidTrigger(i); 124 | expect(trigger.matches).isArrayOfStrings(i, "matches"); 125 | expect(trigger.alternatives).isArrayOfStrings(i, "alternatives"); 126 | 127 | if (trigger.ignore) { 128 | expect(trigger.ignore).isArrayOfStrings(i, "ignores"); 129 | } 130 | 131 | if (trigger.why) { 132 | expect(trigger.why).isString(i); 133 | } 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/scripts/opm_status.js: -------------------------------------------------------------------------------- 1 | // Description: 2 | // get opm status from api 3 | // 4 | // Dependencies: 5 | // None 6 | // 7 | // Commands: 8 | // @bot opm status 9 | // 10 | // Author: 11 | // lauraggit 12 | 13 | const { directMention } = require("@slack/bolt"); 14 | const { 15 | helpMessage, 16 | stats: { incrementStats }, 17 | } = require("../utils"); 18 | 19 | // :greenlight: :redlight: :yellowlight: 20 | const icons = { 21 | Open: ":greenlight:", 22 | Alert: ":yellowlight:", 23 | Closed: ":redlight:", 24 | }; 25 | 26 | module.exports = (robot) => { 27 | helpMessage.registerInteractive( 28 | "OPM's DC office status", 29 | "opm status", 30 | "Working in DC and want to know if the office is closed due to snow or, perhaps, raven attack? Charlie is good friends with the bots over at OPM and will gladly fetch that information for you. No more having to open a web browser all by yourself!", 31 | ); 32 | 33 | robot.message( 34 | directMention, 35 | /opm status/i, 36 | async ({ event: { thread_ts: thread }, say }) => { 37 | incrementStats("OPM status"); 38 | 39 | try { 40 | const data = await fetch( 41 | "https://www.opm.gov/json/operatingstatus.json", 42 | ).then((r) => r.json()); 43 | 44 | say({ 45 | icon_emoji: icons[data.Icon], 46 | text: `${data.StatusSummary} for ${data.AppliesTo}. (<${data.Url}|Read more>)`, 47 | thread_ts: thread, 48 | unfurl_links: false, 49 | unfurl_media: false, 50 | }); 51 | } catch (e) { 52 | say({ 53 | text: "I didn't get a response from OPM, so... what does say?", 54 | thread_ts: thread, 55 | unfurl_links: false, 56 | unfurl_media: false, 57 | }); 58 | } 59 | }, 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/scripts/opm_status.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | const opm = require("./opm_status"); 3 | 4 | describe("OPM status for DC-area offices", () => { 5 | const app = getApp(); 6 | 7 | const message = { 8 | event: { thread_ts: "thread id" }, 9 | say: jest.fn(), 10 | }; 11 | 12 | const json = jest.fn(); 13 | 14 | beforeEach(() => { 15 | jest.resetAllMocks(); 16 | fetch.mockResolvedValue({ json }); 17 | }); 18 | 19 | it("registers a listener", () => { 20 | opm(app); 21 | 22 | expect(app.message).toHaveBeenCalledWith( 23 | expect.any(Function), 24 | /opm status/i, 25 | expect.any(Function), 26 | ); 27 | }); 28 | 29 | it("handles the case where it doesn't get a response", async () => { 30 | opm(app); 31 | const handler = app.getHandler(); 32 | 33 | fetch.mockRejectedValue("error"); 34 | 35 | await handler(message); 36 | 37 | expect(message.say).toHaveBeenCalledWith({ 38 | text: "I didn't get a response from OPM, so... what does say?", 39 | thread_ts: "thread id", 40 | unfurl_links: false, 41 | unfurl_media: false, 42 | }); 43 | }); 44 | 45 | it("handles a successful response", async () => { 46 | opm(app); 47 | const handler = app.getHandler(); 48 | 49 | json.mockResolvedValue({ 50 | AppliesTo: "applies to", 51 | Icon: "Open", 52 | StatusSummary: "summary", 53 | Url: "http://url", 54 | }); 55 | 56 | await handler(message); 57 | 58 | expect(message.say).toHaveBeenCalledWith({ 59 | icon_emoji: ":greenlight:", 60 | text: "summary for applies to. ()", 61 | thread_ts: "thread id", 62 | unfurl_links: false, 63 | unfurl_media: false, 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/scripts/opt-out.js: -------------------------------------------------------------------------------- 1 | const { 2 | optOut: { BRAIN_KEY }, 3 | } = require("../utils"); 4 | 5 | module.exports = (app) => { 6 | app.action( 7 | "opt_out", 8 | async ({ 9 | ack, 10 | action: { 11 | selected_option: { value }, 12 | }, 13 | body: { 14 | user: { id: userId }, 15 | }, 16 | }) => { 17 | ack(); 18 | 19 | const optedOut = app.brain.get(BRAIN_KEY) || {}; 20 | if (!optedOut[value]) { 21 | optedOut[value] = []; 22 | } 23 | 24 | if (!optedOut[value].includes(userId)) { 25 | optedOut[value].push(userId); 26 | app.brain.set(BRAIN_KEY, optedOut); 27 | } 28 | }, 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/scripts/opt-out.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | const optOut = require("./opt-out"); 3 | 4 | describe("opt-out generic action", () => { 5 | const app = getApp(); 6 | optOut(app); 7 | 8 | const handler = app.getActionHandler(); 9 | 10 | beforeEach(() => { 11 | jest.resetAllMocks(); 12 | app.brain.clear(); 13 | }); 14 | 15 | it("subscribes to opt-out actions", () => { 16 | // Call the module again because the mocks have been reset since it was 17 | // called in the setup. Doing this seems less error-prone than moving the 18 | // beforeEach down below. 19 | optOut(app); 20 | expect(app.action).toHaveBeenCalledWith("opt_out", expect.any(Function)); 21 | }); 22 | 23 | describe("adds the user to the appropriate opt-out list if they are not on it", () => { 24 | const message = { 25 | ack: jest.fn(), 26 | action: { selected_option: { value: "action" } }, 27 | body: { user: { id: "user id" } }, 28 | }; 29 | 30 | it("when there is nothing in the brain", async () => { 31 | await handler(message); 32 | 33 | expect(message.ack).toHaveBeenCalled(); 34 | expect(Array.from(app.brain.entries())).toEqual([ 35 | ["OPT_OUT", { action: ["user id"] }], 36 | ]); 37 | }); 38 | 39 | it("when the brain has an opt-out list, but not for this item", async () => { 40 | app.brain.set("OPT_OUT", { otherAction: [] }); 41 | 42 | await handler(message); 43 | 44 | expect(message.ack).toHaveBeenCalled(); 45 | expect(Array.from(app.brain.entries())).toEqual([ 46 | ["OPT_OUT", { action: ["user id"], otherAction: [] }], 47 | ]); 48 | }); 49 | 50 | it("when the brain has an opt-out list for this item", async () => { 51 | app.brain.set("OPT_OUT", { action: [] }); 52 | await handler(message); 53 | 54 | expect(message.ack).toHaveBeenCalled(); 55 | expect(Array.from(app.brain.entries())).toEqual([ 56 | ["OPT_OUT", { action: ["user id"] }], 57 | ]); 58 | }); 59 | 60 | it("but does not add them again if they're already in the opt-out list for this item", async () => { 61 | app.brain.set("OPT_OUT", { action: ["user id"] }); 62 | await handler(message); 63 | 64 | expect(message.ack).toHaveBeenCalled(); 65 | expect(Array.from(app.brain.entries())).toEqual([ 66 | ["OPT_OUT", { action: ["user id"] }], 67 | ]); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/scripts/optimistic-tock.js: -------------------------------------------------------------------------------- 1 | const holidays = require("@18f/us-federal-holidays"); 2 | const moment = require("moment-timezone"); 3 | const scheduler = require("node-schedule"); 4 | const { 5 | optOut, 6 | slack: { sendDirectMessage }, 7 | tock: { get18FUsersWhoHaveNotTocked, get18FTockSlackUsers }, 8 | helpMessage, 9 | } = require("../utils"); 10 | 11 | module.exports = async (app, config = process.env) => { 12 | helpMessage.registerNonInteractive( 13 | "Optimistic Tock", 14 | "Near the end of the last day of the workweek, Charlie will remind Tockable people who haven't submitted their Tock yet.", 15 | ); 16 | 17 | const TOCK_API_URL = config.TOCK_API; 18 | const TOCK_TOKEN = config.TOCK_TOKEN; 19 | 20 | if (!TOCK_API_URL || !TOCK_TOKEN) { 21 | app.logger.warn( 22 | "OptimisticTock disabled: Tock API URL or access token is not set", 23 | ); 24 | return; 25 | } 26 | 27 | const optout = optOut( 28 | "optimistic_tock", 29 | "Optimistic Tock", 30 | "Receive a message near the end of the last work day of the week reminding you to Tock, if you are Tockable and have not yet submitted your time.", 31 | ); 32 | 33 | const reminder = (tz) => async () => { 34 | const message = { 35 | username: "Happy Tock", 36 | icon_emoji: "happytock", 37 | text: "Don't forget to !", 38 | blocks: [ 39 | { 40 | type: "section", 41 | text: { 42 | type: "mrkdwn", 43 | text: "Don't forget to !", 44 | }, 45 | ...optout.button, 46 | }, 47 | ], 48 | }; 49 | 50 | // Get all the folks who have not submitted their current Tock. 51 | const usersWhoNeedToTock = await get18FUsersWhoHaveNotTocked( 52 | moment.tz(tz), 53 | 0, 54 | ); 55 | 56 | // Now get the list of Slacky-Tocky users in the current timezone who 57 | // have not submitted their Tock. Tsk tsk. 58 | const tockSlackUsers = await get18FTockSlackUsers(); 59 | 60 | const slackUsersWhoNeedToTock = tockSlackUsers 61 | .filter((tockUser) => tockUser.tz === tz) 62 | .filter((tockUser) => 63 | usersWhoNeedToTock.some( 64 | (t) => t.email?.toLowerCase() === tockUser.email?.toLowerCase(), 65 | ), 66 | ) 67 | .filter((tockUser) => !optout.isOptedOut(tockUser.slack_id)); 68 | 69 | await Promise.all( 70 | slackUsersWhoNeedToTock.map(async ({ slack_id: slackID }) => { 71 | await sendDirectMessage(slackID, message); 72 | }), 73 | ); 74 | }; 75 | 76 | const scheduleReminders = async () => { 77 | // Westernmost US timezone. Everyone else in the US should be at this time 78 | // or later, so this is where we want to begin. 79 | const day = moment.tz("Pacific/Samoa"); 80 | 81 | // Proceed to the next Friday, then back up if it's a holiday. 82 | while ( 83 | day.format("dddd") !== "Friday" || 84 | day.isSame(moment.tz("2021-05-07", "Pacific/Samoa"), "day") 85 | ) { 86 | day.add(1, "day"); 87 | } 88 | while (holidays.isAHoliday(day.toDate())) { 89 | day.subtract(1, "day"); 90 | } 91 | 92 | const reminderString = day.format("YYYY-MM-DDT16:00:00"); 93 | 94 | const users = await get18FTockSlackUsers(); 95 | const now = moment(); 96 | 97 | // Get a list of unique timezones by putting them into a Set. 98 | new Set(users.map((u) => u.tz)).forEach((tz) => { 99 | const tzReminderTime = moment.tz(reminderString, tz); 100 | if (tzReminderTime.isAfter(now)) { 101 | // If the reminder time is in the past, don't schedule it. That'd be 102 | // a really silly thing to do. 103 | scheduler.scheduleJob(tzReminderTime.toDate(), reminder(tz)); 104 | } 105 | }); 106 | }; 107 | 108 | const scheduleNext = () => { 109 | const nextSunday = moment().day("Sunday"); 110 | // "Not after" rather than "before" to handle the edge where these 111 | // two are identical. So... Sundays. Also the tests. 112 | if (!nextSunday.isAfter(moment())) { 113 | nextSunday.add(7, "days"); 114 | } 115 | 116 | scheduler.scheduleJob(nextSunday.toDate(), async () => { 117 | await scheduleReminders(); 118 | await scheduleNext(); 119 | }); 120 | }; 121 | 122 | await scheduleReminders(); 123 | await scheduleNext(); 124 | }; 125 | -------------------------------------------------------------------------------- /src/scripts/pugs.js: -------------------------------------------------------------------------------- 1 | const { directMention } = require("@slack/bolt"); 2 | const { 3 | helpMessage, 4 | stats: { incrementStats }, 5 | } = require("../utils"); 6 | const sample = require("../utils/sample"); 7 | 8 | const pugs = [ 9 | "https://i.imgur.com/kXngLij.png", 10 | "https://i.imgur.com/3wR2qg8.jpg", 11 | "https://i.imgur.com/vMnlr1D.png", 12 | "https://i.imgur.com/Fhhg2D4.png", 13 | "https://i.imgur.com/LxVSPck.png", 14 | "https://i.imgur.com/Fihh8o1.png", 15 | "https://i.imgur.com/jUzKI5Z.png", 16 | "https://i.imgur.com/x8VRFM1.png", 17 | "https://i.imgur.com/dpM7RQU.png", 18 | "https://i.imgur.com/zUN5r5p.png", // 10 19 | "https://i.imgur.com/Z6CRlh1.png", 20 | "https://i.imgur.com/3zXJqqU.png", 21 | "https://i.imgur.com/Ok7I6H2.png", 22 | "https://i.imgur.com/7Rn8WGV.png", 23 | "https://i.imgur.com/8b7dm9l.png", // 15 24 | "https://i.imgur.com/2NmdgpY.png", 25 | "https://i.imgur.com/WtjRob1.png", 26 | "https://i.imgur.com/9EFvd5s.png", 27 | "https://i.imgur.com/G27DsB2.png", 28 | "https://i.imgur.com/Jy2ssIK.png", 29 | ]; 30 | 31 | const makePugs = (count = 1) => 32 | [...Array(count)].map(() => ({ 33 | type: "image", 34 | title: { type: "plain_text", text: "a pug!" }, 35 | image_url: sample(pugs), 36 | alt_text: "a pug", 37 | })); 38 | 39 | module.exports = (app) => { 40 | helpMessage.registerInteractive( 41 | "Pug Me", 42 | "pug me", 43 | "Do you like pugs? Do you want a picture of a pug? Charlie can satisfy your craving with a random picture of a cute pug!", 44 | true, 45 | ); 46 | helpMessage.registerInteractive( 47 | "Pug Bomb", 48 | "pug bomb ", 49 | "Do you love pugs so much that you want to see several of them? Charlie can deliver! Defaults to three cutes.", 50 | true, 51 | ); 52 | 53 | app.message(directMention, /pug me/i, async ({ say }) => { 54 | incrementStats("pug bot: one"); 55 | say({ blocks: makePugs() }); 56 | }); 57 | 58 | app.message(directMention, /pug bomb ?(\d+)?/i, ({ context, say }) => { 59 | incrementStats("pug bot: multiple"); 60 | const count = +context.matches[1] || 3; 61 | say({ blocks: makePugs(count) }); 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/scripts/pugs.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | 3 | const script = require("./pugs"); 4 | 5 | describe("pug bot", () => { 6 | const app = getApp(); 7 | 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | describe("registers message handlers", () => { 13 | it("for a single pug", () => { 14 | script(app); 15 | expect(app.message).toHaveBeenCalledWith( 16 | expect.any(Function), 17 | /pug me/i, 18 | expect.any(Function), 19 | ); 20 | }); 21 | 22 | it("for multiple pugs", () => { 23 | script(app); 24 | expect(app.message).toHaveBeenCalledWith( 25 | expect.any(Function), 26 | /pug bomb ?(\d+)?/i, 27 | expect.any(Function), 28 | ); 29 | }); 30 | }); 31 | 32 | describe("handles pug requests", () => { 33 | it("for a single pug", () => { 34 | script(app); 35 | const handler = app.getHandler(0); 36 | const say = jest.fn(); 37 | 38 | handler({ say }); 39 | 40 | expect(say).toHaveBeenCalledWith({ 41 | blocks: [ 42 | { 43 | type: "image", 44 | title: { type: "plain_text", text: "a pug!" }, 45 | image_url: expect.stringMatching( 46 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 47 | ), 48 | alt_text: "a pug", 49 | }, 50 | ], 51 | }); 52 | }); 53 | 54 | describe("for multiple pugs", () => { 55 | const say = jest.fn(); 56 | 57 | let handler; 58 | beforeEach(() => { 59 | script(app); 60 | handler = app.getHandler(1); 61 | }); 62 | 63 | it("if the count is provided, uses the count", () => { 64 | handler({ context: { matches: [null, 2] }, say }); 65 | 66 | expect(say).toHaveBeenCalledWith({ 67 | blocks: [ 68 | { 69 | type: "image", 70 | title: { type: "plain_text", text: "a pug!" }, 71 | image_url: expect.stringMatching( 72 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 73 | ), 74 | alt_text: "a pug", 75 | }, 76 | { 77 | type: "image", 78 | title: { type: "plain_text", text: "a pug!" }, 79 | image_url: expect.stringMatching( 80 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 81 | ), 82 | alt_text: "a pug", 83 | }, 84 | ], 85 | }); 86 | }); 87 | 88 | it("if the count is not provided, defaults to 3", () => { 89 | handler({ context: { matches: [null] }, say }); 90 | 91 | expect(say).toHaveBeenCalledWith({ 92 | blocks: [ 93 | { 94 | type: "image", 95 | title: { type: "plain_text", text: "a pug!" }, 96 | image_url: expect.stringMatching( 97 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 98 | ), 99 | alt_text: "a pug", 100 | }, 101 | { 102 | type: "image", 103 | title: { type: "plain_text", text: "a pug!" }, 104 | image_url: expect.stringMatching( 105 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 106 | ), 107 | alt_text: "a pug", 108 | }, 109 | { 110 | type: "image", 111 | title: { type: "plain_text", text: "a pug!" }, 112 | image_url: expect.stringMatching( 113 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 114 | ), 115 | alt_text: "a pug", 116 | }, 117 | ], 118 | }); 119 | }); 120 | 121 | it("if the count is provided but is not numeric, defaults to 3", () => { 122 | handler({ context: { matches: [null, "bob"] }, say }); 123 | 124 | expect(say).toHaveBeenCalledWith({ 125 | blocks: [ 126 | { 127 | type: "image", 128 | title: { type: "plain_text", text: "a pug!" }, 129 | image_url: expect.stringMatching( 130 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 131 | ), 132 | alt_text: "a pug", 133 | }, 134 | { 135 | type: "image", 136 | title: { type: "plain_text", text: "a pug!" }, 137 | image_url: expect.stringMatching( 138 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 139 | ), 140 | alt_text: "a pug", 141 | }, 142 | { 143 | type: "image", 144 | title: { type: "plain_text", text: "a pug!" }, 145 | image_url: expect.stringMatching( 146 | /^https:\/\/i\.imgur\.com\/.{7}\.(jpg|png)$/, 147 | ), 148 | alt_text: "a pug", 149 | }, 150 | ], 151 | }); 152 | }); 153 | }); 154 | }); 155 | }); 156 | -------------------------------------------------------------------------------- /src/scripts/q-expand.js: -------------------------------------------------------------------------------- 1 | // Description: 2 | // Ask for a Q* TTS initialism to be fully expanded to its full glory 3 | // 4 | // pulls information from config/q-expand.csv in this repo 5 | // that csv file got its information from two Google Doc org charts 6 | // FAS TTS High level: 7 | // https://docs.google.com/presentation/d/1otRbhoGRN4LfDnWpN6zZ0ymjPpTBW3t79pIiuBREkCY/edit?usp=sharing 8 | // TTS Solutions: 9 | // https://docs.google.com/presentation/d/10Qfq1AaQh74q76Pik99kQedvshLBo0qLWZGsH-nrV0w/edit?usp=sharing 10 | // 11 | // XXX somewhere there's another google doc about TTS Client Services (18f, etc.), but can't find the link now 12 | // 13 | // Dependencies: 14 | // "csv-parse": "5.0.4" 15 | // 16 | // 17 | // Commands: 18 | // qex[p] INITIALISM 19 | // 20 | // examples: 21 | // qexp QUEAAD 22 | // qex qq2 23 | 24 | const { parse } = require("csv-parse"); 25 | const fs = require("fs"); 26 | const { 27 | stats: { incrementStats }, 28 | helpMessage, 29 | } = require("../utils"); 30 | 31 | function getCsvData() { 32 | const csvData = {}; 33 | return new Promise((resolve) => { 34 | fs.createReadStream("config/q-expand.csv") 35 | .pipe(parse({ delimiter: "," })) 36 | .on("data", (csvrow) => { 37 | csvData[csvrow[0]] = csvrow[1]; 38 | }) 39 | .on("end", () => { 40 | resolve(csvData); 41 | }); 42 | }); 43 | } 44 | 45 | function getCodeLine(code, csvData) { 46 | return [ 47 | "|".repeat(code.length - 1), 48 | "└──", 49 | // If this is a contractor code, replace the lowercase c with -C again 50 | code.endsWith("c") ? `${code.slice(0, code.length - 1)}-C` : code, 51 | ": ", 52 | code.endsWith("c") ? "Contractor" : (csvData[code] ?? "???"), 53 | ].join(""); 54 | } 55 | 56 | function qExpander(expandThis, csvData) { 57 | // change -C to c, if it exists 58 | // lowercase c to disambiguate from other C endings not related to contractor 59 | const initialism = expandThis 60 | .toUpperCase() 61 | .replace(/-C$/, "c") 62 | .replace(/\*$/, ""); 63 | 64 | const fullResponse = [expandThis.toUpperCase()]; 65 | 66 | if (expandThis.endsWith("*") && csvData[initialism]) { 67 | const tree = new Map(); 68 | 69 | const children = Object.keys(csvData) 70 | .filter((k) => k.startsWith(initialism) && k !== initialism) 71 | .sort(); 72 | 73 | for (const child of children) { 74 | for (let substr = child.length - 1; substr >= 1; substr -= 1) { 75 | const parent = child.slice(0, substr); 76 | if (!tree.has(parent)) { 77 | tree.set(parent, new Set()); 78 | } 79 | 80 | tree.get(parent).add(child.slice(0, substr + 1)); 81 | } 82 | } 83 | 84 | const addChildrenToResponse = (code) => { 85 | for (const child of tree.get(code) ?? []) { 86 | addChildrenToResponse(child); 87 | fullResponse.push(getCodeLine(child, csvData)); 88 | } 89 | }; 90 | addChildrenToResponse(initialism); 91 | 92 | // For the root requested initialism, distinguish it from the rest by 93 | // putting asterisks around it. Unfortunately that won't bold it, but it's 94 | // something, at least? 95 | fullResponse.push( 96 | getCodeLine(initialism, csvData).replace(/└──(.*)$/, "└──*$1*"), 97 | ); 98 | 99 | for (let substr = initialism.length - 1; substr > 0; substr -= 1) { 100 | fullResponse.push(getCodeLine(initialism.slice(0, substr), csvData)); 101 | } 102 | } else { 103 | // work backwards from full initialism back on char at a time 104 | for (let substr = initialism.length; substr >= 1; substr -= 1) { 105 | const thisOne = initialism.slice(0, substr); 106 | fullResponse.push(getCodeLine(thisOne, csvData)); 107 | } 108 | } 109 | 110 | // return the response block 111 | return fullResponse.join("\n"); 112 | } 113 | 114 | module.exports = (app) => { 115 | helpMessage.registerInteractive( 116 | "Q-Expander", 117 | "qex [code]", 118 | "Ever wonder what the Q* initialisms are after everyone's names? Each letter describes where a person fits in the organization. Charlie can show you what those codes mean in tree-form, so you can see the organizational hierarchy!", 119 | ); 120 | 121 | const csvData = module.exports.getCsvData(); 122 | app.message( 123 | /^qexp?\s+([a-z0-9-]{1,8}\*?)$/i, 124 | async ({ message: { thread_ts: thread }, context, say }) => { 125 | incrementStats("qex expander"); 126 | 127 | const initialismSearch = context.matches[1]; 128 | const resp = qExpander(initialismSearch, await csvData); 129 | const response = { 130 | icon_emoji: ":tts:", 131 | username: "Q-Expander", 132 | text: `\`\`\`${resp}\`\`\``, 133 | thread_ts: thread, 134 | }; 135 | say(response); 136 | }, 137 | ); 138 | }; 139 | module.exports.getCsvData = getCsvData; 140 | -------------------------------------------------------------------------------- /src/scripts/three-paycheck-month.js: -------------------------------------------------------------------------------- 1 | const { directMention } = require("@slack/bolt"); 2 | const moment = require("moment"); 3 | const { 4 | stats: { incrementStats }, 5 | helpMessage, 6 | } = require("../utils"); 7 | 8 | // We need a known pay date to work from. 9 | const REFERENCE_DATE = moment.utc("2022-01-07"); 10 | 11 | const firstFridayOfMonth = (date) => { 12 | const friday = moment.utc(date).hour(0).minute(0).second(0).date(1); 13 | while (friday.day() !== 5) { 14 | friday.add(1, "day"); 15 | } 16 | return friday; 17 | }; 18 | 19 | const isPayDate = (date) => { 20 | const weeks = moment.duration(date.diff(REFERENCE_DATE)).as("weeks"); 21 | if (Math.round(weeks) % 2 === 0) { 22 | return true; 23 | } 24 | return false; 25 | }; 26 | 27 | const isThreePaycheckMonth = (date) => { 28 | const payday = firstFridayOfMonth(date); 29 | if (isPayDate(payday)) { 30 | const second = moment.utc(payday).add(14, "days"); 31 | const third = moment.utc(payday).add(28, "days"); 32 | if (second.month() === payday.month() && third.month() === payday.month()) { 33 | return true; 34 | } 35 | } 36 | return false; 37 | }; 38 | 39 | const getNextThreePaycheckMonth = (from = Date.now()) => { 40 | // Get the first day of the month 41 | const date = moment.utc(from).hour(0).minute(0).second(0); 42 | 43 | while (!isThreePaycheckMonth(date)) { 44 | date.add(1, "month"); 45 | } 46 | 47 | return date.date(1); 48 | }; 49 | 50 | module.exports = (app) => { 51 | helpMessage.registerInteractive( 52 | "Three-paycheck month", 53 | "three paycheck", 54 | "Sometimes we get three paychecks in a single month. Some people splurge on crab legs on those months, and Charlie can help you plan by letting you know when the next three-paycheck month is.", 55 | true, 56 | ); 57 | 58 | app.message( 59 | directMention, 60 | // Be very permissive in what we listen for.e 61 | // https://regexper.com/#%2F.*%28three%7C3%29%5Cb.*pay%5B-%5Cs%5D%3F%28check%7Cday%29.*%2F 62 | /.*(three|3)\b.*pay[-\s]?(check|day).*/i, 63 | ({ message: { thread_ts: thread }, say }) => { 64 | incrementStats("3 paycheck month"); 65 | const next = getNextThreePaycheckMonth(); 66 | 67 | say({ 68 | text: `The next 3-paycheck month is ${next.format("MMMM yyyy")}.`, 69 | thread_ts: thread, 70 | }); 71 | }, 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/scripts/three-paycheck-month.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | const bot = require("./three-paycheck-month"); 3 | 4 | describe("three-paycheck month bot tells you when the next three-paycheck month is", () => { 5 | const app = getApp(); 6 | 7 | beforeAll(() => { 8 | jest.useFakeTimers(); 9 | }); 10 | 11 | beforeEach(() => { 12 | jest.setSystemTime(0); 13 | jest.resetAllMocks(); 14 | }); 15 | 16 | afterAll(() => { 17 | jest.useRealTimers(); 18 | }); 19 | 20 | it("registers a handler at setup", () => { 21 | bot(app); 22 | 23 | expect(app.message).toHaveBeenCalledWith( 24 | expect.any(Function), 25 | expect.any(RegExp), 26 | expect.any(Function), 27 | ); 28 | }); 29 | 30 | describe("responds with the next 3-paycheck month", () => { 31 | let handler; 32 | const say = jest.fn(); 33 | 34 | const payload = { message: { thread_ts: "thread id" }, say }; 35 | 36 | beforeEach(() => { 37 | bot(app); 38 | handler = app.getHandler(); 39 | }); 40 | 41 | describe("if the current month is a 3-paycheck month", () => { 42 | it("and the month starts on a Friday", () => { 43 | // Remember that Javascript months are 0-indexed, so 3 is actually April 44 | jest.setSystemTime(new Date(2022, 3, 1)); 45 | handler(payload); 46 | 47 | expect(say).toHaveBeenCalledWith({ 48 | text: "The next 3-paycheck month is April 2022.", 49 | thread_ts: "thread id", 50 | }); 51 | }); 52 | 53 | it("and the month does not start on a Friday", () => { 54 | // And 8 is actually September 55 | jest.setSystemTime(new Date(2022, 8, 12)); 56 | handler(payload); 57 | 58 | expect(say).toHaveBeenCalledWith({ 59 | text: "The next 3-paycheck month is September 2022.", 60 | thread_ts: "thread id", 61 | }); 62 | }); 63 | }); 64 | 65 | describe("if the current month is not a 3-paycheck month", () => { 66 | it("but the first Friday is a paycheck", () => { 67 | jest.setSystemTime(new Date(2022, 2, 9)); 68 | handler(payload); 69 | 70 | expect(say).toHaveBeenCalledWith({ 71 | text: "The next 3-paycheck month is April 2022.", 72 | thread_ts: "thread id", 73 | }); 74 | }); 75 | 76 | it("and the first Friday is not a paycheck", () => { 77 | jest.setSystemTime(new Date(2022, 4, 9)); 78 | handler(payload); 79 | 80 | expect(say).toHaveBeenCalledWith({ 81 | text: "The next 3-paycheck month is September 2022.", 82 | thread_ts: "thread id", 83 | }); 84 | }); 85 | }); 86 | 87 | it("if the next 3-paycheck month is in the next year", () => { 88 | jest.setSystemTime(new Date(2022, 10, 4)); 89 | handler(payload); 90 | 91 | expect(say).toHaveBeenCalledWith({ 92 | text: "The next 3-paycheck month is March 2023.", 93 | thread_ts: "thread id", 94 | }); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/scripts/timezone.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const { 3 | optOut, 4 | slack: { getSlackUsersInConversation, postEphemeralMessage }, 5 | helpMessage, 6 | } = require("../utils"); 7 | 8 | const TIMEZONES = { 9 | akt: "America/Anchorage", 10 | akst: "America/Anchorage", 11 | akdt: "America/Anchorage", 12 | at: "America/Puerto_Rico", 13 | adt: "America/Puerto_Rico", 14 | ast: "America/Puerto_Rico", 15 | ":central-time-zone:": "America/Chicago", 16 | ct: "America/Chicago", 17 | cdt: "America/Chicago", 18 | cst: "America/Chicago", 19 | ":eastern-time-zone:": "America/New_York", 20 | et: "America/New_York", 21 | edt: "America/New_York", 22 | est: "America/New_York", 23 | ":mountain-time-zone:": "America/Denver", 24 | mt: "America/Denver", 25 | mdt: "America/Denver", 26 | mst: "America/Denver", 27 | ":pacific-time-zone:": "America/Los_Angeles", 28 | pt: "America/Los_Angeles", 29 | pdt: "America/Los_Angeles", 30 | pst: "America/Los_Angeles", 31 | }; 32 | 33 | const matcher = 34 | /(\d{1,2}:\d{2}\s?(am|pm)?)\s?(((ak|a|c|e|m|p)(s|d)?t)|:(eastern|central|mountain|pacific)-time-zone:)?/i; 35 | 36 | module.exports = (app) => { 37 | helpMessage.registerNonInteractive( 38 | "Helpful tau bot/Baby Tock", 39 | "If Charlie sees something it recognizes as a time posted into chat, it will send a message that only you can see, letting you know what that time is in your timezone. Charlie tries really hard.", 40 | ); 41 | 42 | const optout = optOut( 43 | "handy_tau_bot", 44 | "Baby Tock (Handy Tau Bot)", 45 | "When someone posts a message containing a time, see that time translated into your local time below it.", 46 | ); 47 | 48 | app.message(matcher, async (msg) => { 49 | const { channel, text, thread_ts: thread, user } = msg.event; 50 | 51 | const { 52 | user: { tz: authorTimezone }, 53 | } = await msg.client.users.info({ user }); 54 | 55 | let users = await getSlackUsersInConversation(msg); 56 | let m = null; 57 | let ampm = null; 58 | 59 | const matches = [...text.matchAll(RegExp(matcher, "gi"))]; 60 | 61 | // If there aren't any matches, that can be because this was crossposted. 62 | // We don't want to have the bot respond to those because the authorship of 63 | // the message (and thus the origin timezone) gets goofy. 64 | if (matches.length === 0) { 65 | return; 66 | } 67 | 68 | matches.forEach(([, time, ampmStr, timezone]) => { 69 | const sourceTz = timezone 70 | ? TIMEZONES[timezone.toLowerCase()] 71 | : authorTimezone; 72 | 73 | if (m === null) { 74 | ampm = ampmStr; 75 | m = moment.tz( 76 | `${time.trim()}${ampm ? ` ${ampm}` : ""}`, 77 | "hh:mm a", 78 | sourceTz, 79 | ); 80 | } 81 | 82 | users = users 83 | .filter(({ deleted, id, is_bot: bot, tz }) => { 84 | if (deleted || bot) { 85 | return false; 86 | } 87 | 88 | if (optout.isOptedOut(id)) { 89 | return false; 90 | } 91 | 92 | // If the timezone was specified in the message, filter out the people 93 | // who are in that timezone. 94 | if (timezone) { 95 | if (tz === sourceTz) { 96 | return false; 97 | } 98 | } else if (id === user) { 99 | return false; 100 | } 101 | 102 | return true; 103 | }) 104 | .map(({ id, tz }) => ({ id, tz })); 105 | }); 106 | 107 | // if the detected time is invalid, nothing should be sent to users 108 | if (!m.isValid()) { 109 | return; 110 | } 111 | 112 | users.forEach(({ id, tz }) => { 113 | postEphemeralMessage({ 114 | channel, 115 | icon_emoji: ":timebot:", 116 | user: id, 117 | username: "Handy Tau-bot", 118 | text: `That's ${m 119 | .clone() 120 | .tz(tz) 121 | .format(`h:mm${ampm ? " a" : ""}`)} for you!`, 122 | thread_ts: thread, 123 | blocks: [ 124 | { 125 | type: "section", 126 | text: { 127 | type: "mrkdwn", 128 | text: `That's ${m 129 | .clone() 130 | .tz(tz) 131 | .format(`h:mm${ampm ? " a" : ""}`)} for you!`, 132 | }, 133 | ...optout.button, 134 | }, 135 | ], 136 | }); 137 | }); 138 | }); 139 | }; 140 | -------------------------------------------------------------------------------- /src/scripts/tock-line.js: -------------------------------------------------------------------------------- 1 | // Description: 2 | // Inspect the data in redis easily 3 | // 4 | // Commands: 5 | // @bot set tock line - Associates a tock line with the current channel 6 | // @bot tock line - Display the tock line associated with the current channel, if any 7 | 8 | const { directMention } = require("@slack/bolt"); 9 | const { 10 | stats: { incrementStats }, 11 | helpMessage, 12 | } = require("../utils"); 13 | 14 | const getTockLines = (app) => { 15 | let tockLines = app.brain.get("tockLines"); 16 | if (!tockLines) { 17 | tockLines = {}; 18 | } 19 | return tockLines; 20 | }; 21 | 22 | module.exports = (app) => { 23 | helpMessage.registerInteractive( 24 | "Tock line (get)", 25 | "tock line", 26 | "Not sure what Tock line to bill this project to? Charlie might know! If someone has set a tock line for the channel, Charlie will gladly tell you what it is.", 27 | true, 28 | ); 29 | helpMessage.registerInteractive( 30 | "Tock line (set)", 31 | "set tock line", 32 | "Let Charlie help you keep track of the Tock line for a channel!", 33 | true, 34 | ); 35 | 36 | app.message( 37 | directMention, 38 | /tock( line)?$/i, 39 | ({ event: { channel, text, thread_ts: thread }, say }) => { 40 | incrementStats("tock line: get"); 41 | 42 | const tockLines = getTockLines(app); 43 | if (tockLines[channel]) { 44 | say({ 45 | icon_emoji: ":happytock:", 46 | text: `The tock line for <#${channel}> is \`${tockLines[channel]}\``, 47 | thread_ts: thread, 48 | }); 49 | } else { 50 | const botUserIDMatch = text.match(/^<@([^>]+)>/); 51 | const botUserID = botUserIDMatch[1]; 52 | 53 | say({ 54 | icon_emoji: ":happytock:", 55 | text: `I don't know a tock line for this room. To set one, say \`<@${botUserID}> set tock line \``, 56 | thread_ts: thread, 57 | }); 58 | } 59 | }, 60 | ); 61 | 62 | app.message( 63 | directMention, 64 | /set tock( line)? (.*)$/i, 65 | ({ context: { matches }, event: { channel, thread_ts: thread }, say }) => { 66 | incrementStats("tock line: set"); 67 | 68 | const tockLines = getTockLines(app); 69 | tockLines[channel] = matches[2]; 70 | app.brain.set("tockLines", tockLines); 71 | say({ 72 | icon_emoji: ":happytock:", 73 | text: "Okay, I set the tock line for this room", 74 | thread_ts: thread, 75 | }); 76 | }, 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/scripts/tock-line.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | const tock = require("./tock-line"); 3 | 4 | describe("tock line", () => { 5 | const app = getApp(); 6 | 7 | const message = { 8 | context: { 9 | matches: ["whole match", "optional lines", "tock line"], 10 | }, 11 | event: { 12 | channel: "channel id", 13 | text: "<@bot id> tock line", 14 | thread_ts: "thread id", 15 | }, 16 | say: jest.fn(), 17 | }; 18 | 19 | beforeAll(() => { 20 | jest.resetAllMocks(); 21 | }); 22 | 23 | it("hooks up the right listeners", () => { 24 | tock(app); 25 | 26 | expect(app.message).toHaveBeenCalledWith( 27 | expect.any(Function), 28 | /tock( line)?$/i, 29 | expect.any(Function), 30 | ); 31 | 32 | expect(app.message).toHaveBeenCalledWith( 33 | expect.any(Function), 34 | /set tock( line)? (.*)$/i, 35 | expect.any(Function), 36 | ); 37 | }); 38 | 39 | it("reports if there is no Tock line set for the channel", () => { 40 | tock(app); 41 | const handler = app.getHandler(); 42 | 43 | handler(message); 44 | 45 | expect(message.say).toHaveBeenCalledWith({ 46 | icon_emoji: ":happytock:", 47 | text: "I don't know a tock line for this room. To set one, say `<@bot id> set tock line `", 48 | thread_ts: "thread id", 49 | }); 50 | }); 51 | 52 | it("responds with the Tock line if configured", () => { 53 | tock(app); 54 | const handler = app.getHandler(); 55 | 56 | app.brain.set("tockLines", { "channel id": "1234" }); 57 | 58 | handler(message); 59 | 60 | expect(message.say).toHaveBeenCalledWith({ 61 | icon_emoji: ":happytock:", 62 | text: "The tock line for <#channel id> is `1234`", 63 | thread_ts: "thread id", 64 | }); 65 | }); 66 | 67 | it("sets the tock line for a given room", () => { 68 | tock(app); 69 | const handler = app.getHandler(1); 70 | 71 | handler(message); 72 | 73 | expect(app.brain.get("tockLines")).toEqual({ "channel id": "tock line" }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/scripts/tock-ops-report.js: -------------------------------------------------------------------------------- 1 | const holidays = require("@18f/us-federal-holidays"); 2 | const moment = require("moment-timezone"); 3 | const scheduler = require("node-schedule"); 4 | const { 5 | dates: { getCurrentWorkWeek }, 6 | slack: { postMessage }, 7 | tock: { get18FUsersWhoHaveNotTocked }, 8 | } = require("../utils"); 9 | 10 | module.exports = (app, config = process.env) => { 11 | if (!config.TOCK_API || !config.TOCK_TOKEN) { 12 | app.logger.warn( 13 | "Tock compliance report disabled: Tock API URL or access token is not set", 14 | ); 15 | return; 16 | } 17 | 18 | const TRUANT_REPORT_TIMEZONE = 19 | config.ANGRY_TOCK_TIMEZONE || "America/New_York"; 20 | const TRUANT_REPORT_TIME = moment( 21 | config.ANGRY_TOCK_SECOND_TIME || "16:00", 22 | "HH:mm", 23 | ); 24 | 25 | const TRUANT_REPORT_TO = (config.ANGRY_TOCK_REPORT_TO || "#18f-supes").split( 26 | ",", 27 | ); 28 | 29 | const getNextReportTime = () => { 30 | // The reporting time for the current work week would be the first day of 31 | // that working week. 32 | const reportTime = moment 33 | .tz(getCurrentWorkWeek()[0], TRUANT_REPORT_TIMEZONE) 34 | .hour(TRUANT_REPORT_TIME.hour()) 35 | .minute(TRUANT_REPORT_TIME.minute()) 36 | .second(0); 37 | 38 | // If we've already passed the report time for the current work week, 39 | // jump to the next Monday, and then scoot forward over any holidays. 40 | if (reportTime.isBefore(moment())) { 41 | reportTime.add(7, "days").day(1); 42 | while (holidays.isAHoliday(reportTime.toDate())) { 43 | reportTime.add(1, "day"); 44 | } 45 | } 46 | 47 | return reportTime; 48 | }; 49 | 50 | const report = async () => { 51 | const untockedUsers = await get18FUsersWhoHaveNotTocked( 52 | moment.tz(TRUANT_REPORT_TIMEZONE), 53 | ); 54 | 55 | if (untockedUsers.length > 0) { 56 | const untockedList = untockedUsers 57 | .map(({ username }) => `• ${username}`) 58 | .join("\n"); 59 | 60 | const tockComplianceReport = { 61 | attachments: [ 62 | { 63 | fallback: untockedList, 64 | color: "#FF0000", 65 | text: untockedList, 66 | }, 67 | ], 68 | username: "Angry Tock", 69 | icon_emoji: ":angrytock:", 70 | text: "*The following users have not yet reported their time on Tock:*", 71 | }; 72 | 73 | await Promise.all( 74 | TRUANT_REPORT_TO.map((channel) => 75 | postMessage({ ...tockComplianceReport, channel }), 76 | ), 77 | ); 78 | } 79 | }; 80 | 81 | const scheduleNextReport = () => { 82 | const when = getNextReportTime(); 83 | 84 | scheduler.scheduleJob(when.toDate(), async () => { 85 | await report(); 86 | 87 | // Once we've run the report, wait a minute and then schedule the next 88 | setTimeout(() => { 89 | scheduleNextReport(); 90 | }, 60 * 1000).unref(); 91 | }); 92 | }; 93 | 94 | scheduleNextReport(); 95 | }; 96 | -------------------------------------------------------------------------------- /src/scripts/travel-team.js: -------------------------------------------------------------------------------- 1 | const holidays = require("@18f/us-federal-holidays"); 2 | const moment = require("moment"); 3 | const { 4 | stats: { incrementStats }, 5 | helpMessage, 6 | } = require("../utils"); 7 | 8 | const closedDays = ["Saturday", "Sunday"]; 9 | 10 | const getChannelName = (() => { 11 | let allChannels = null; 12 | return async (client, channelID) => { 13 | if (allChannels === null) { 14 | const response = await client.conversations.list(); 15 | allChannels = response.channels.reduce( 16 | (channels, { id, name }) => ({ ...channels, [id]: name }), 17 | {}, 18 | ); 19 | } 20 | return allChannels[channelID]; 21 | }; 22 | })(); 23 | 24 | const travelIsClosed = (day = moment()) => 25 | holidays.isAHoliday(day.toDate()) || closedDays.includes(day.format("dddd")); 26 | 27 | const getNextWorkday = () => { 28 | const m = moment().add(1, "day"); 29 | while (travelIsClosed(m)) { 30 | m.add(1, "day"); 31 | } 32 | return m.format("dddd"); 33 | }; 34 | 35 | const pastResponses = []; 36 | const getHasRespondedToUserRecently = (userID) => { 37 | // First, remove all previous responses that were over three hours ago 38 | const threeHoursAgo = Date.now() - 3 * 60 * 60 * 1000; 39 | for (let i = 0; i < pastResponses.length; i += 1) { 40 | if (pastResponses[i].time <= threeHoursAgo) { 41 | pastResponses.splice(i, 1); 42 | i -= 1; 43 | } 44 | } 45 | 46 | // Now check if any of the remaining responses are for this user 47 | return pastResponses.some((p) => p.user === userID); 48 | }; 49 | 50 | module.exports = (robot) => { 51 | helpMessage.registerNonInteractive( 52 | "Travel team", 53 | "Did you know that the TTS Travel team takes weekends and holidays too? It's true, they do! And Charlie knows it too. If you drop a question or comment in the travel channel on a closed day, Charlie will remind you that the office is closed and offer some helpful tips to get you through. It will also let you know when the Travel team will be back in the office!", 54 | ); 55 | 56 | robot.message( 57 | /.*/, 58 | async ({ client, event: { channel, thread_ts: thread, user }, say }) => { 59 | const channelName = await getChannelName(client, channel); 60 | 61 | if ( 62 | channelName === "travel" && 63 | travelIsClosed() && 64 | !getHasRespondedToUserRecently(user) 65 | ) { 66 | incrementStats("travel team weekend/holiday notice"); 67 | 68 | pastResponses.push({ user, time: Date.now() }); 69 | say({ 70 | icon_emoji: ":tts:", 71 | text: `Hi <@${user}>. The TTS travel team is unavailable on weekends and holidays. If you need to change your flight for approved travel, contact AdTrav at (877) 472-6716. For after-hours emergency travel authorizations, see . For other travel-related issues, such as an approval in Concur, please drop a new message in this channel ${getNextWorkday()} morning and someone will respond promptly.`, 72 | thread_ts: thread, 73 | username: "TTS Travel Team", 74 | }); 75 | } 76 | }, 77 | ); 78 | }; 79 | -------------------------------------------------------------------------------- /src/scripts/zen.js: -------------------------------------------------------------------------------- 1 | // Description: 2 | // Display GitHub zen message from https://api.github.com/zen API 3 | // 4 | // Dependencies: 5 | // None 6 | // 7 | // Configuration: 8 | // None 9 | // 10 | // Commands: 11 | // zen - Display GitHub zen message 12 | // 13 | // Author: 14 | // anildigital 15 | // 16 | const { directMention } = require("@slack/bolt"); 17 | 18 | const { 19 | helpMessage, 20 | stats: { incrementStats }, 21 | } = require("../utils"); 22 | 23 | module.exports = (app) => { 24 | helpMessage.registerInteractive( 25 | "Zen Bot", 26 | "zen", 27 | "Fetches and displays a random product, techy, or code-focused message of zen. Read it, and breathe.", 28 | true, 29 | ); 30 | 31 | app.message( 32 | directMention, 33 | /\bzen\b/i, 34 | async ({ event: { thread_ts: thread }, say }) => { 35 | incrementStats("zen"); 36 | const text = await fetch("https://api.github.com/zen").then((r) => 37 | r.text(), 38 | ); 39 | say({ text, thread_ts: thread }); 40 | }, 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/scripts/zen.test.js: -------------------------------------------------------------------------------- 1 | const { getApp } = require("../utils/test"); 2 | const zen = require("./zen"); 3 | 4 | describe("zen bot", () => { 5 | const app = getApp(); 6 | const text = jest.fn(); 7 | 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | fetch.mockResolvedValue({ text }); 11 | }); 12 | 13 | it("subscribes to direct mentions that include the word 'zen'", () => { 14 | zen(app); 15 | 16 | expect(app.message).toHaveBeenCalledWith( 17 | expect.any(Function), 18 | /\bzen\b/i, 19 | expect.any(Function), 20 | ); 21 | }); 22 | 23 | it("fetches a zen message from the GitHub API when triggered", async () => { 24 | zen(app); 25 | const handler = app.getHandler(); 26 | 27 | const message = { 28 | event: { 29 | thread_ts: "thread timestamp", 30 | }, 31 | say: jest.fn(), 32 | }; 33 | 34 | text.mockResolvedValue("zen message"); 35 | 36 | await handler(message); 37 | 38 | expect(message.say).toHaveBeenCalledWith({ 39 | text: "zen message", 40 | thread_ts: "thread timestamp", 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/utils/cache.js: -------------------------------------------------------------------------------- 1 | const CACHE_MAX_LIFE = 4 * 60 * 60 * 1000; // 4 hours 2 | 3 | const cache = (() => { 4 | const privateCache = new Map(); 5 | 6 | // Clear out anything over a maximum lifetime, regularly, to prevent memory 7 | // leaks. Otherwise caches of months-old Tock data could end up sticking 8 | // around forever and ever, amen. 9 | setInterval(() => { 10 | const expiry = Date.now(); 11 | for (const [key, { timestamp }] of privateCache) { 12 | if (timestamp + CACHE_MAX_LIFE <= expiry) { 13 | privateCache.delete(key); 14 | } 15 | } 16 | }, CACHE_MAX_LIFE).unref(); 17 | // Unref the timer, so that it won't keep the Node process alive. By default, 18 | // Node will keep a process alive as long as anything is waiting on the main 19 | // loop, but this interval timer shouldn't keep the process alive. 20 | 21 | const cacheFunction = async (key, lifetimeInMinutes, callback) => { 22 | const lifetimeInMS = lifetimeInMinutes * 60 * 1000; 23 | 24 | // If the key isn't in the cache, default to a timestamp of -Infinity so we 25 | // will always use the callback. 26 | const { timestamp, value } = privateCache.get(key) ?? { 27 | timestamp: -Infinity, 28 | }; 29 | 30 | // The cached value is older than the allowed lifetime, so fetch it anew. 31 | if (timestamp + lifetimeInMS < Date.now()) { 32 | const newValue = await callback(); 33 | 34 | privateCache.set(key, { timestamp: Date.now(), value: newValue }); 35 | return newValue; 36 | } 37 | 38 | return value; 39 | }; 40 | 41 | cacheFunction.clear = () => { 42 | privateCache.clear(); 43 | }; 44 | 45 | return cacheFunction; 46 | })(); 47 | 48 | module.exports = { cache }; 49 | -------------------------------------------------------------------------------- /src/utils/cache.test.js: -------------------------------------------------------------------------------- 1 | describe("utils / cache", () => { 2 | let cache; 3 | 4 | beforeAll(() => { 5 | jest.useFakeTimers(); 6 | }); 7 | 8 | beforeEach(() => { 9 | // Completely reload the module for each test, so we're always starting with 10 | // an empty cache. Also, the module needs to be loaded after the timers have 11 | // been faked. 12 | jest.resetModules(); 13 | jest.setSystemTime(0); 14 | cache = require("./cache").cache; // eslint-disable-line global-require 15 | }); 16 | 17 | afterAll(() => { 18 | jest.useRealTimers(); 19 | }); 20 | 21 | // Make this the first time-based test, otherwise we get into quirky issues 22 | // where the clock has ticked some and then stuff gets added to the cache 23 | // so that it doesn't expire on the next auto-clean. That is correct 24 | // behavior, but it's hard to account for. Having this test first moots it. 25 | it("clears itself of things that are more than 4 hours old", async () => { 26 | const FOUR_HOURS = 4 * 60 * 60 * 1000; 27 | 28 | const callback = jest.fn().mockResolvedValue(""); 29 | 30 | // Put something into the cache 31 | await cache("key", 30, callback); 32 | 33 | // Reset the callback history so we'll know if it's been called again 34 | // later. 35 | callback.mockClear(); 36 | 37 | // Call from the cache to prove that the callback isn't called. 38 | await cache("key", 300, callback); 39 | expect(callback).not.toHaveBeenCalled(); 40 | 41 | // Zoom to the future! 42 | await jest.advanceTimersByTime(FOUR_HOURS); 43 | 44 | await cache("key", 300, callback); 45 | expect(callback).toHaveBeenCalled(); 46 | }); 47 | 48 | it("stores cached results for the specified time", async () => { 49 | const lifetimeInMinutes = 3; 50 | const callback = jest.fn().mockResolvedValue("first call"); 51 | 52 | const result1 = await cache("test key", lifetimeInMinutes, callback); 53 | expect(result1).toEqual("first call"); 54 | 55 | // Change the behavior of the callback. It shouldn't get called again, but 56 | // if it does, we want the test to fail. 57 | callback.mockResolvedValue("second call"); 58 | 59 | const result2 = await cache("test key", lifetimeInMinutes, callback); 60 | expect(result2).toEqual(result1); 61 | 62 | // Tick forward. We need to go one tick past the lifetime in case the 63 | // comparison is strictly less than instead of less than or equal. 64 | jest.advanceTimersByTime(lifetimeInMinutes * 60 * 1000 + 1); 65 | 66 | // Now we should get a new call to the callback. 67 | const result3 = await cache("test key", lifetimeInMinutes, callback); 68 | expect(result3).toEqual("second call"); 69 | }); 70 | 71 | it("can empty the cache", async () => { 72 | const lifetimeInMinutes = 3; 73 | const callback = jest.fn().mockResolvedValue("first call"); 74 | 75 | const result1 = await cache("test key", lifetimeInMinutes, callback); 76 | expect(result1).toEqual("first call"); 77 | 78 | // Change the behavior of the callback, then clear the cache. If this 79 | // clear fails, then the test should fail. 80 | callback.mockResolvedValue("second call"); 81 | cache.clear(); 82 | 83 | const result2 = await cache("test key", lifetimeInMinutes, callback); 84 | expect(result2).toEqual("second call"); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/utils/dates.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const holidays = require("@18f/us-federal-holidays"); 3 | 4 | const getNextHoliday = (timezone = "America/New_York") => { 5 | const now = moment.tz(timezone); 6 | 7 | return holidays 8 | .allForYear(now.year()) 9 | .concat(holidays.allForYear(now.year() + 1)) 10 | .map((h) => ({ 11 | ...h, 12 | date: moment.tz(h.dateString, "YYYY-MM-DD", timezone), 13 | })) 14 | .filter((h) => h.date.isAfter(now)) 15 | .shift(); 16 | }; 17 | 18 | const getNextElectionDayFrom = (from) => { 19 | // In the US, federal election day is the first Tuesday after the first 20 | // Monday in November of even-numbered years. 21 | 22 | const date = moment.utc(from); 23 | date.hour(0); 24 | date.minute(0); 25 | date.second(0); 26 | date.millisecond(0); 27 | 28 | // If it's already after November, shift forward to the next January and 29 | // compute from there. Months are 0-indexed because of Reasons™. 30 | if (date.month() > 10) { 31 | date.month(1); 32 | date.year(date.year() + 1); 33 | } 34 | 35 | // If we're in an odd-numbered year, advance one year. Federal elections are 36 | // only in even-numbered years. 37 | if (date.year() % 2 === 1) { 38 | date.year(date.year() + 1); 39 | } 40 | 41 | date.month(10); 42 | date.date(1); 43 | 44 | // Election day is the first Tuesday AFTER the first Monday of November. So if 45 | // the first day of November is Tuesday, we fast-forward a week. Otherwise, we 46 | // fast-forward to the first Tuesday. 47 | if (date.day() === 2) { 48 | date.date(date.date() + 7); 49 | } else { 50 | while (date.day() !== 2) { 51 | date.add(1, "days"); 52 | } 53 | } 54 | 55 | return date; 56 | }; 57 | 58 | const getNextElectionDay = () => { 59 | const date = moment.utc(); 60 | 61 | const election = getNextElectionDayFrom(date); 62 | 63 | // If the provided election day is in the past, jump forward a month and then 64 | // get it again. 65 | if (election.isBefore(moment.utc())) { 66 | date.add(1, "month"); 67 | return getNextElectionDayFrom(date); 68 | } 69 | 70 | return election; 71 | }; 72 | 73 | const getCurrentWorkWeek = () => { 74 | // Start with Monday of the current week, and then walk forward if there are 75 | // holidays to contend with. 76 | const start = moment.utc().day(1); 77 | while (holidays.isAHoliday(start.toDate(), { utc: true })) { 78 | start.add(1, "day"); 79 | } 80 | 81 | // Now add all of the rest of the working days this week. Loop until we hit 82 | // Saturday, and skip any days that are holidays. 83 | const days = [start]; 84 | let next = start.clone().add(1, "day"); 85 | do { 86 | if (!holidays.isAHoliday(next.toDate(), { utc: true })) { 87 | days.push(next); 88 | } 89 | next = next.clone().add(1, "day"); 90 | } while (next.day() < 6); 91 | 92 | // Return just the date string. Eliminate the time portion. 93 | return days.map((date) => date.format("YYYY-MM-DD")); 94 | }; 95 | 96 | module.exports = { getCurrentWorkWeek, getNextHoliday, getNextElectionDay }; 97 | -------------------------------------------------------------------------------- /src/utils/dates.test.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | const { 3 | getCurrentWorkWeek, 4 | getNextElectionDay, 5 | getNextHoliday, 6 | } = require("./dates"); 7 | 8 | describe("date utility library", () => { 9 | beforeAll(() => { 10 | jest.useFakeTimers(); 11 | }); 12 | 13 | afterAll(() => { 14 | jest.useRealTimers(); 15 | }); 16 | 17 | describe("gets the next holiday", () => { 18 | it("defaults to America/New_York time", async () => { 19 | // Midnight on May 28 in eastern timezone 20 | jest.setSystemTime( 21 | +moment.tz("2012-05-28", "YYYY-MM-DD", "America/New_York").format("x"), 22 | ); 23 | 24 | const nextHoliday = getNextHoliday(); 25 | 26 | expect(moment(nextHoliday.date).isValid()).toBe(true); 27 | 28 | // remove this from the object match, otherwise 29 | // it becomes a whole big thing, dependent on 30 | // moment not changing its internal object structure 31 | delete nextHoliday.date; 32 | 33 | expect(nextHoliday).toEqual({ 34 | name: "Independence Day", 35 | dateString: "2012-07-04", 36 | }); 37 | }); 38 | 39 | it("respects the configured timezone", async () => { 40 | // Midnight on May 28 in US eastern timezone. Because our reminder 41 | // timezone is US central timezone, "now" is still May 27, so the 42 | // "next" holiday should be May 28 - Memorial Day 43 | jest.setSystemTime( 44 | +moment.tz("2012-05-28", "YYYY-MM-DD", "America/New_York").format("x"), 45 | ); 46 | 47 | const nextHoliday = getNextHoliday("America/Chicago"); 48 | 49 | expect(moment(nextHoliday.date).isValid()).toBe(true); 50 | 51 | // remove this from the object match, otherwise 52 | // it becomes a whole big thing, dependent on 53 | // moment not changing its internal object structure 54 | delete nextHoliday.date; 55 | 56 | expect(nextHoliday).toEqual({ 57 | name: "Memorial Day", 58 | dateString: "2012-05-28", 59 | }); 60 | }); 61 | }); 62 | 63 | describe("gets the next federal election day", () => { 64 | describe("if election day for this year has already passed", () => { 65 | it("because it is November but after election day", () => { 66 | jest.setSystemTime(+moment.utc("2022-11-09", "YYYY-MM-DD").format("x")); 67 | 68 | const electionDay = getNextElectionDay(); 69 | 70 | expect(electionDay.isSame(moment.utc("2024-11-05"))).toBe(true); 71 | }); 72 | 73 | it("because it is now December", () => { 74 | jest.setSystemTime(+moment.utc("2022-12-03", "YYYY-MM-DD").format("x")); 75 | 76 | const electionDay = getNextElectionDay(); 77 | 78 | expect(electionDay.isSame(moment.utc("2024-11-05"))).toBe(true); 79 | }); 80 | }); 81 | 82 | it("skips odd-numbered years", () => { 83 | jest.setSystemTime(+moment.utc("2021-03-01", "YYYY-MM-DD").format("x")); 84 | 85 | const electionDay = getNextElectionDay(); 86 | 87 | expect(electionDay.isSame(moment.utc("2022-11-08"))).toBe(true); 88 | }); 89 | 90 | it("goes the second Tuesday of November if the first Tuesday is before the first Monday", () => { 91 | jest.setSystemTime(+moment.utc("2022-10-01", "YYYY-MM-DD").format("x")); 92 | 93 | const electionDay = getNextElectionDay(); 94 | 95 | expect(electionDay.isSame(moment.utc("2022-11-08"))).toBe(true); 96 | }); 97 | }); 98 | 99 | describe("gets the current working week, accounting for holidays", () => { 100 | it("correctly handles a week with no holidays", () => { 101 | // Non-holiday week 102 | jest.setSystemTime( 103 | +moment.tz("2022-11-17", "America/New_York").format("x"), 104 | ); 105 | 106 | const dates = getCurrentWorkWeek(); 107 | 108 | expect(dates).toEqual([ 109 | "2022-11-14", 110 | "2022-11-15", 111 | "2022-11-16", 112 | "2022-11-17", 113 | "2022-11-18", 114 | ]); 115 | }); 116 | 117 | it("correctly handles a week with a Monday holiday", () => { 118 | // Christmas 2022, falls on a Sunday, but is observed on Monday 119 | jest.setSystemTime( 120 | +moment.tz("2022-12-26", "America/New_York").format("x"), 121 | ); 122 | 123 | const dates = getCurrentWorkWeek(); 124 | 125 | expect(dates).toEqual([ 126 | "2022-12-27", 127 | "2022-12-28", 128 | "2022-12-29", 129 | "2022-12-30", 130 | ]); 131 | }); 132 | 133 | it("correctly handles a week with a Friday holiday", () => { 134 | // Veterans Day 2022, falls on a Friday 135 | jest.setSystemTime( 136 | +moment.tz("2022-11-08", "America/New_York").format("x"), 137 | ); 138 | 139 | const dates = getCurrentWorkWeek(); 140 | 141 | expect(dates).toEqual([ 142 | "2022-11-07", 143 | "2022-11-08", 144 | "2022-11-09", 145 | "2022-11-10", 146 | ]); 147 | }); 148 | 149 | it("correctly handles a week with a midweek holiday", () => { 150 | // Thanksgiving 2022, falls on a Thursday 151 | jest.setSystemTime( 152 | +moment.tz("2022-11-24", "America/New_York").format("x"), 153 | ); 154 | 155 | const dates = getCurrentWorkWeek(); 156 | 157 | expect(dates).toEqual([ 158 | "2022-11-21", 159 | "2022-11-22", 160 | "2022-11-23", 161 | "2022-11-25", 162 | ]); 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/utils/helpMessage.js: -------------------------------------------------------------------------------- 1 | const interactive = Symbol("interactive"); 2 | const noninteractive = Symbol("non-interactive"); 3 | 4 | const modules = new Map([ 5 | [interactive, []], 6 | [noninteractive, []], 7 | ]); 8 | 9 | module.exports = { 10 | getHelp() { 11 | return modules; 12 | }, 13 | 14 | registerInteractive(name, trigger, helpText, directMention = false) { 15 | modules.get(interactive).push({ name, trigger, helpText, directMention }); 16 | }, 17 | 18 | registerNonInteractive(name, helpText) { 19 | modules.get(noninteractive).push({ name, helpText }); 20 | }, 21 | 22 | type: { 23 | interactive, 24 | noninteractive, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils/helpMessage.test.js: -------------------------------------------------------------------------------- 1 | describe("help message registrar and reporter", () => { 2 | const load = () => 3 | new Promise((resolve) => { 4 | jest.isolateModules(() => { 5 | const module = require("./helpMessage"); // eslint-disable-line global-require 6 | resolve(module); 7 | }); 8 | }); 9 | 10 | let helpMessage; 11 | 12 | beforeEach(async () => { 13 | helpMessage = await load(); 14 | }); 15 | 16 | describe("lets me register interactive bots", () => { 17 | it("assumes a direct mention is not required", () => { 18 | helpMessage.registerInteractive("bot", "trigger", "help text"); 19 | 20 | expect(helpMessage.getHelp().has(helpMessage.type.interactive)).toBe( 21 | true, 22 | ); 23 | expect(helpMessage.getHelp().get(helpMessage.type.interactive)).toEqual([ 24 | { 25 | name: "bot", 26 | trigger: "trigger", 27 | helpText: "help text", 28 | directMention: false, 29 | }, 30 | ]); 31 | }); 32 | 33 | it("accepts the direct mention flag as an argument", () => { 34 | helpMessage.registerInteractive("bot", "trigger", "help text", true); 35 | 36 | expect(helpMessage.getHelp().has(helpMessage.type.interactive)).toBe( 37 | true, 38 | ); 39 | expect(helpMessage.getHelp().get(helpMessage.type.interactive)).toEqual([ 40 | { 41 | name: "bot", 42 | trigger: "trigger", 43 | helpText: "help text", 44 | directMention: true, 45 | }, 46 | ]); 47 | }); 48 | }); 49 | 50 | it("lets me register noninteractive bots", () => { 51 | helpMessage.registerNonInteractive("bot", "help text"); 52 | 53 | expect(helpMessage.getHelp().has(helpMessage.type.noninteractive)).toBe( 54 | true, 55 | ); 56 | expect(helpMessage.getHelp().get(helpMessage.type.noninteractive)).toEqual([ 57 | { 58 | name: "bot", 59 | helpText: "help text", 60 | }, 61 | ]); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/utils/holidays.js: -------------------------------------------------------------------------------- 1 | const emojis = new Map([ 2 | ["New Year's Day", ":baby-new-year:"], 3 | ["Birthday of Martin Luther King, Jr.", ":mlk:"], 4 | ["Washington's Birthday", ":george:"], 5 | ["Memorial Day", ":poppy-flowers:"], 6 | ["Juneteenth", ":juneteenth-star:"], 7 | ["Independence Day", ":fireworks:"], 8 | ["Labor Day", ":wecandoit:"], 9 | ["Columbus Day", ":ohio:"], 10 | ["Veterans Day", ":salute-you:"], 11 | ["Thanksgiving Day", ":turkey:"], 12 | ["Christmas Day", ":christmas_tree:"], 13 | ]); 14 | 15 | module.exports = { emojis }; 16 | -------------------------------------------------------------------------------- /src/utils/holidays.test.js: -------------------------------------------------------------------------------- 1 | const holidays = require("./holidays"); 2 | 3 | describe("holidays utilities", () => { 4 | it("maps holiday names to emoji strings", () => { 5 | const keys = Array.from(holidays.emojis.keys()); 6 | const values = Array.from(holidays.emojis.values()); 7 | 8 | // Just assert that all keys and values are strings. That's really all we 9 | // can do here. :shrugging-person-made-of-symbols: 10 | expect(keys.filter((key) => typeof key === "string")).toEqual(keys); 11 | expect(values.filter((value) => typeof value === "string")).toEqual(values); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/homepage.js: -------------------------------------------------------------------------------- 1 | const didYouKnow = []; 2 | const interactive = []; 3 | 4 | let refresh = async () => {}; 5 | 6 | module.exports = { 7 | getDidYouKnow(userId) { 8 | return didYouKnow.flatMap((cb) => cb(userId)); 9 | }, 10 | 11 | getInteractive(userId) { 12 | return interactive.flatMap((cb) => cb(userId)); 13 | }, 14 | 15 | refresh: (userId, client) => refresh(userId, client), 16 | 17 | registerDidYouKnow(callback) { 18 | didYouKnow.push(callback); 19 | }, 20 | 21 | registerInteractive(callback) { 22 | interactive.push(callback); 23 | }, 24 | 25 | registerRefresh(callback) { 26 | refresh = callback; 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/homepage.test.js: -------------------------------------------------------------------------------- 1 | describe("homepage utility", () => { 2 | let homepage; 3 | beforeEach(() => { 4 | // Reset the module before each test so we can be sure we're starting from 5 | // a clean state, and anything we register in a test isn't lingering around 6 | jest.resetModules(); 7 | homepage = require("./homepage"); // eslint-disable-line global-require 8 | }); 9 | 10 | describe("supports the 'did you know section'", () => { 11 | it("returns an empty array if nothing is registered", () => { 12 | expect(homepage.getDidYouKnow("user id")).toEqual([]); 13 | }); 14 | 15 | it("returns an array of responses from everything that's registered", () => { 16 | const fn1 = jest.fn(); 17 | const fn2 = jest.fn(); 18 | const fn3 = jest.fn(); 19 | 20 | fn1.mockReturnValue("one"); 21 | fn2.mockReturnValue(2); 22 | fn3.mockReturnValue(["three"]); 23 | 24 | homepage.registerDidYouKnow(fn1); 25 | homepage.registerDidYouKnow(fn2); 26 | homepage.registerDidYouKnow(fn3); 27 | 28 | expect(homepage.getDidYouKnow("user id")).toEqual(["one", 2, "three"]); 29 | expect(fn1).toHaveBeenCalledWith("user id"); 30 | expect(fn2).toHaveBeenCalledWith("user id"); 31 | expect(fn3).toHaveBeenCalledWith("user id"); 32 | }); 33 | }); 34 | 35 | describe("supports the interactive section", () => { 36 | it("returns an empty array if nothing is registered", () => { 37 | expect(homepage.getInteractive("user id")).toEqual([]); 38 | }); 39 | 40 | it("returns an array of responses from everything that's registered", () => { 41 | const fn1 = jest.fn(); 42 | const fn2 = jest.fn(); 43 | const fn3 = jest.fn(); 44 | 45 | fn1.mockReturnValue("one"); 46 | fn2.mockReturnValue(2); 47 | fn3.mockReturnValue(["three"]); 48 | 49 | homepage.registerInteractive(fn1); 50 | homepage.registerInteractive(fn2); 51 | homepage.registerInteractive(fn3); 52 | 53 | expect(homepage.getInteractive("user id")).toEqual(["one", 2, "three"]); 54 | expect(fn1).toHaveBeenCalledWith("user id"); 55 | expect(fn2).toHaveBeenCalledWith("user id"); 56 | expect(fn3).toHaveBeenCalledWith("user id"); 57 | }); 58 | }); 59 | 60 | describe("supports refreshing the homepage", () => { 61 | it("does not fail if there is no registered refresher", () => { 62 | homepage.refresh(); 63 | }); 64 | 65 | it("calls the registered refresher", () => { 66 | const refresher = jest.fn(); 67 | homepage.registerRefresh(refresher); 68 | 69 | homepage.refresh("user id", "client"); 70 | 71 | expect(refresher).toHaveBeenCalledWith("user id", "client"); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | const { cache } = require("./cache"); 2 | const dates = require("./dates"); 3 | const helpMessage = require("./helpMessage"); 4 | const holidays = require("./holidays"); 5 | const homepage = require("./homepage"); 6 | const optOut = require("./optOut"); 7 | const slack = require("./slack"); 8 | const stats = require("./stats"); 9 | const tock = require("./tock"); 10 | 11 | module.exports = { 12 | cache, 13 | dates, 14 | helpMessage, 15 | holidays, 16 | homepage, 17 | optOut, 18 | slack, 19 | stats, 20 | tock, 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/index.test.js: -------------------------------------------------------------------------------- 1 | const utils = require("./index"); 2 | 3 | describe("utils / index", () => { 4 | it("exports things", () => { 5 | expect(Object.keys(utils)).toEqual([ 6 | "cache", 7 | "dates", 8 | "helpMessage", 9 | "holidays", 10 | "homepage", 11 | "optOut", 12 | "slack", 13 | "stats", 14 | "tock", 15 | ]); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/utils/optOut.js: -------------------------------------------------------------------------------- 1 | const brain = require("../brain"); 2 | 3 | const BRAIN_KEY = "OPT_OUT"; 4 | 5 | const optOutOptions = []; 6 | 7 | const optOut = (key, name, description) => { 8 | const optOuts = brain.get(BRAIN_KEY) || {}; 9 | 10 | // If the brain hasn't seen this key before, initialize it and save it. There 11 | // is no good reason all the other things that use opt-outs should have to 12 | // think about whether or not these are initialized. They are! 13 | if (!optOuts[key]) { 14 | optOuts[key] = []; 15 | brain.set(BRAIN_KEY, optOuts); 16 | } 17 | 18 | // Keep a list of all the options we've registered so they can be queried by 19 | // other scripts, such as the handy options view on Charlie's home page 20 | optOutOptions.push({ key, name, description }); 21 | 22 | return { 23 | button: { 24 | accessory: { 25 | action_id: "opt_out", 26 | type: "overflow", 27 | options: [ 28 | { 29 | text: { 30 | type: "plain_text", 31 | text: "Don't show me this anymore", 32 | }, 33 | value: key, 34 | }, 35 | ], 36 | }, 37 | }, 38 | isOptedOut: (userId) => { 39 | const optOutList = brain.get(BRAIN_KEY) || {}; 40 | return (optOutList[key] || []).includes(userId); 41 | }, 42 | }; 43 | }; 44 | 45 | module.exports = optOut; 46 | module.exports.BRAIN_KEY = BRAIN_KEY; 47 | module.exports.options = optOutOptions; 48 | -------------------------------------------------------------------------------- /src/utils/optOut.test.js: -------------------------------------------------------------------------------- 1 | const { brain } = require("./test"); 2 | const optOutModule = require("./optOut"); 3 | 4 | describe("opt-out utility", () => { 5 | let optOut; 6 | 7 | beforeAll(() => { 8 | optOut = optOutModule("test"); 9 | }); 10 | 11 | beforeEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | it("initializes an opt-out object", () => { 16 | expect(optOut).toEqual({ 17 | button: { 18 | accessory: { 19 | action_id: "opt_out", 20 | type: "overflow", 21 | options: [ 22 | { 23 | text: { 24 | type: "plain_text", 25 | text: "Don't show me this anymore", 26 | }, 27 | value: "test", 28 | }, 29 | ], 30 | }, 31 | }, 32 | isOptedOut: expect.any(Function), 33 | }); 34 | }); 35 | 36 | it("returns true if the user ID is in the opt-out list", () => { 37 | brain.get.mockReturnValue({ test: ["user id"] }); 38 | expect(optOut.isOptedOut("user id")).toEqual(true); 39 | }); 40 | 41 | it("returns false if the user ID is not in the opt-out list", () => { 42 | brain.get.mockReturnValue({ test: ["different user id"] }); 43 | expect(optOut.isOptedOut("user id")).toEqual(false); 44 | }); 45 | 46 | it("keeps track of all the things that have opted out", () => { 47 | optOutModule("other-thing", "Other Thing Name", "description"); 48 | expect(optOutModule.options).toEqual([ 49 | { key: "test", name: undefined, description: undefined }, 50 | { 51 | key: "other-thing", 52 | name: "Other Thing Name", 53 | description: "description", 54 | }, 55 | ]); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/utils/sample.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a randomly-selected item from an array 3 | * @template T 4 | * @param {T[]} arr Array of values to be sampled 5 | * @param {Number} [randomValue] random value that can be injected for more deterministic behavior 6 | * @return {T=} an item from the array (or undefined if empty array) 7 | */ 8 | function sample(arr, randomValue = Math.random()) { 9 | return arr[ 10 | Math.min(Math.max(0, Math.floor(randomValue * arr.length)), arr.length - 1) 11 | ]; 12 | } 13 | 14 | module.exports = sample; 15 | -------------------------------------------------------------------------------- /src/utils/sample.test.js: -------------------------------------------------------------------------------- 1 | const sample = require("./sample"); 2 | 3 | describe("utils / sample", () => { 4 | const arr = ["a", "b", "c", "d", "e"]; 5 | 6 | it("selects an item from an array based on randomValue and clamps it to the array bounds", () => { 7 | expect(sample(arr, 0)).toEqual("a"); 8 | expect(sample(arr, 0.999)).toEqual("e"); 9 | expect(sample(arr, -1)).toEqual("a"); 10 | expect(sample(arr, 100)).toEqual("e"); 11 | }); 12 | 13 | it("still returns a random item when randomValue is not provided", () => { 14 | expect(arr).toContain(sample(arr)); 15 | }); 16 | 17 | it("returns undefined when an array is empty", () => { 18 | expect(sample([])).toBeUndefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/utils/slack.js: -------------------------------------------------------------------------------- 1 | const { cache } = require("./cache"); 2 | 3 | let defaultClient = {}; 4 | 5 | const setClient = (client) => { 6 | defaultClient = client; 7 | }; 8 | 9 | const addEmojiReaction = async (msg, reaction) => { 10 | const { 11 | client, 12 | event: { channel, ts: timestamp }, 13 | } = msg; 14 | 15 | return client.reactions.add({ name: reaction, channel, timestamp }); 16 | }; 17 | 18 | const getChannelID = (() => { 19 | const channelIDs = new Map(); 20 | 21 | return async (channelName, { SLACK_TOKEN } = process.env) => { 22 | if (!channelIDs.get(channelName)) { 23 | const all = []; 24 | let cursor; 25 | 26 | do { 27 | /* eslint-disable no-await-in-loop */ 28 | // The no-await-in-loop rule is there to encourage parallelization and 29 | // using Promise.all to collect the waiting promises. However, in this 30 | // case, the iterations of the loop are dependent on each other and 31 | // cannot be parallelized, so just disable the rule. 32 | 33 | const { 34 | channels, 35 | response_metadata: { next_cursor: nextCursor }, 36 | } = await defaultClient.conversations.list({ 37 | cursor, 38 | token: SLACK_TOKEN, 39 | }); 40 | 41 | cursor = nextCursor; 42 | all.push(...channels); 43 | 44 | /* eslint-enable no-await-in-loop */ 45 | // But be sure to turn the rule back on. 46 | } while (cursor); 47 | 48 | all.forEach(({ name, id }) => channelIDs.set(name, id)); 49 | } 50 | return channelIDs.get(channelName); 51 | }; 52 | })(); 53 | 54 | /** 55 | * Fetch a list of Slack users in the workspace that this bot is in. 56 | * @async 57 | * @returns {Promise>} A list of Slack users. 58 | */ 59 | const getSlackUsers = async ({ SLACK_TOKEN } = process.env) => 60 | cache("get slack users", 1440, async () => { 61 | const all = []; 62 | let cursor; 63 | 64 | do { 65 | /* eslint-disable no-await-in-loop */ 66 | // The no-await-in-loop rule is there to encourage parallelization and 67 | // using Promise.all to collect the waiting promises. However, in this 68 | // case, the iterations of the loop are dependent on each other and 69 | // cannot be parallelized, so just disable the rule. 70 | const { 71 | members, 72 | response_metadata: { next_cursor: nextCursor }, 73 | } = await defaultClient.users.list({ 74 | cursor, 75 | token: SLACK_TOKEN, 76 | }); 77 | 78 | cursor = nextCursor; 79 | all.push(...members); 80 | 81 | /* eslint-enable no-await-in-loop */ 82 | // But be sure to turn the rule back on. 83 | } while (cursor); 84 | 85 | return all; 86 | }); 87 | 88 | const getSlackUsersInConversation = async ({ 89 | client = defaultClient, 90 | event: { channel }, 91 | }) => 92 | cache(`get slack users in conversation ${channel}`, 10, async () => { 93 | const { members: channelUsers } = await client.conversations.members({ 94 | channel, 95 | }); 96 | const allUsers = await getSlackUsers(); 97 | 98 | return allUsers.filter(({ id }) => channelUsers.includes(id)); 99 | }); 100 | 101 | const postEphemeralMessage = async (message, { SLACK_TOKEN } = process.env) => { 102 | await defaultClient.chat.postEphemeral({ 103 | ...message, 104 | token: SLACK_TOKEN, 105 | }); 106 | }; 107 | 108 | const postEphemeralResponse = async (toMsg, message, config = process.env) => { 109 | const { 110 | event: { channel, thread_ts: thread, user }, 111 | } = toMsg; 112 | await postEphemeralMessage( 113 | { 114 | ...message, 115 | user, 116 | channel, 117 | thread_ts: thread, 118 | }, 119 | config, 120 | ); 121 | }; 122 | 123 | const postFile = async (message, { SLACK_TOKEN } = process.env) => 124 | defaultClient.filesUploadV2({ 125 | ...message, 126 | token: SLACK_TOKEN, 127 | }); 128 | 129 | const postMessage = async (message, { SLACK_TOKEN } = process.env) => 130 | defaultClient.chat.postMessage({ 131 | ...message, 132 | token: SLACK_TOKEN, 133 | }); 134 | 135 | const sendDirectMessage = async ( 136 | to, 137 | message, 138 | { SLACK_TOKEN } = process.env, 139 | ) => { 140 | const { 141 | channel: { id }, 142 | } = await defaultClient.conversations.open({ 143 | token: SLACK_TOKEN, 144 | users: Array.isArray(to) ? to.join(",") : to, 145 | }); 146 | 147 | postMessage( 148 | { 149 | ...message, 150 | channel: id, 151 | }, 152 | { SLACK_TOKEN }, 153 | ); 154 | }; 155 | 156 | module.exports = { 157 | addEmojiReaction, 158 | getChannelID, 159 | getSlackUsers, 160 | getSlackUsersInConversation, 161 | postEphemeralMessage, 162 | postEphemeralResponse, 163 | postFile, 164 | postMessage, 165 | sendDirectMessage, 166 | setClient, 167 | }; 168 | -------------------------------------------------------------------------------- /src/utils/stats.js: -------------------------------------------------------------------------------- 1 | const brain = require("../brain"); 2 | 3 | const BRAIN_KEY = "charlie-usage-stats"; 4 | 5 | const incrementStats = (script) => { 6 | const stats = brain.get(BRAIN_KEY) ?? {}; 7 | stats[script] = (stats[script] ?? 0) + 1; 8 | brain.set(BRAIN_KEY, stats); 9 | }; 10 | 11 | module.exports = { incrementStats, BRAIN_KEY }; 12 | -------------------------------------------------------------------------------- /src/utils/stats.test.js: -------------------------------------------------------------------------------- 1 | const { brain } = require("./test"); 2 | 3 | const { incrementStats, BRAIN_KEY } = require("./stats"); 4 | 5 | describe("bot stats helper", () => { 6 | beforeEach(() => { 7 | jest.resetAllMocks(); 8 | }); 9 | 10 | it("sets a bot's stats to 1 if there are currently no stats", () => { 11 | brain.get.mockReturnValue(); 12 | incrementStats("bot1"); 13 | 14 | expect(brain.set).toHaveBeenCalledWith(BRAIN_KEY, { 15 | bot1: 1, 16 | }); 17 | }); 18 | 19 | it("sets a bot's stats to 1 if it is currently undefined", () => { 20 | brain.get.mockReturnValue({ bot1: 7, bot2: 3 }); 21 | incrementStats("bot3"); 22 | 23 | expect(brain.set).toHaveBeenCalledWith(BRAIN_KEY, { 24 | bot1: 7, 25 | bot2: 3, 26 | bot3: 1, 27 | }); 28 | }); 29 | 30 | it("increments a bot's stats if the bot already has stats", () => { 31 | brain.get.mockReturnValue({ bot1: 7, bot2: 3, bot3: 9 }); 32 | incrementStats("bot3"); 33 | 34 | expect(brain.set).toHaveBeenCalledWith(BRAIN_KEY, { 35 | bot1: 7, 36 | bot2: 3, 37 | bot3: 10, 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/utils/test.js: -------------------------------------------------------------------------------- 1 | const brain = require("../brain"); 2 | const { 3 | cache, 4 | dates, 5 | helpMessage, 6 | homepage, 7 | optOut, 8 | slack, 9 | stats, 10 | tock, 11 | } = require("./index"); 12 | 13 | // Mock fetch and the utility functions, to make it easier for tests to use. 14 | jest.mock("../brain"); 15 | jest.mock("./index"); 16 | 17 | global.fetch = jest.fn(); 18 | 19 | module.exports = { 20 | getApp: () => { 21 | const action = jest.fn(); 22 | const event = jest.fn(); 23 | const message = jest.fn(); 24 | 25 | return { 26 | action, 27 | brain: new Map(), 28 | event, 29 | logger: { 30 | debug: jest.fn(), 31 | error: jest.fn(), 32 | getLevel: jest.fn(), 33 | info: jest.fn(), 34 | setLevel: jest.fn(), 35 | setName: jest.fn(), 36 | warn: jest.fn(), 37 | }, 38 | message, 39 | 40 | /** 41 | * Get the action handler that the bot registers. 42 | * @param {Number} index Optional. The index of the handler to fetch. For 43 | * Bots that register multiple handlers. This is in the order that the 44 | * handlers are registered. Defaults to 0, the first handler. 45 | */ 46 | getActionHandler: (index = 0) => { 47 | if (typeof index === "string") { 48 | return action.mock.calls 49 | .filter(([actionName]) => actionName === index) 50 | .pop() 51 | .slice(-1) 52 | .pop(); 53 | } 54 | 55 | if (action.mock.calls.length > index) { 56 | return action.mock.calls[index].slice(-1).pop(); 57 | } 58 | return null; 59 | }, 60 | 61 | /** 62 | * Get the event handler that the bot registers. 63 | * @param {Number} index Optional. The index of the handler to fetch. For 64 | * Bots that register multiple handlers. This is in the order that the 65 | * handlers are registered. Defaults to 0, the first handler. 66 | */ 67 | getEventHandler: (index = 0) => { 68 | if (event.mock.calls.length > index) { 69 | return event.mock.calls[index].slice(-1).pop(); 70 | } 71 | return null; 72 | }, 73 | 74 | /** 75 | * Get the handler that the bot registers. 76 | * @param {Number} index Optional. The index of the handler to fetch. For 77 | * Bots that register multiple handlers. This is in the order that the 78 | * handlers are registered. Defaults to 0, the first handler. 79 | */ 80 | getHandler: (index = 0) => { 81 | if (message.mock.calls.length > index) { 82 | return message.mock.calls[index].slice(-1).pop(); 83 | } 84 | return null; 85 | }, 86 | }; 87 | }, 88 | brain, 89 | utils: { 90 | cache, 91 | dates, 92 | helpMessage, 93 | homepage, 94 | optOut, 95 | slack, 96 | stats, 97 | tock, 98 | }, 99 | }; 100 | -------------------------------------------------------------------------------- /src/utils/tock.js: -------------------------------------------------------------------------------- 1 | const path = require("node:path"); 2 | const { cache } = require("./cache"); 3 | const { getSlackUsers } = require("./slack"); 4 | 5 | const getFromTock = async (url) => 6 | cache(`tock fetch: ${url}`, 10, async () => { 7 | const absoluteURL = new URL(process.env.TOCK_API); 8 | absoluteURL.pathname = path.join(absoluteURL.pathname, url); 9 | 10 | return fetch(absoluteURL, { 11 | headers: { Authorization: `Token ${process.env.TOCK_TOKEN}` }, 12 | }).then((r) => r.json()); 13 | }); 14 | 15 | /** 16 | * Fetch a list of Tock users that are current 18F employees. 17 | * @async 18 | * @returns {Promise>} A list of Tock users 19 | */ 20 | const getCurrent18FTockUsers = async () => { 21 | // First get user data. This is what tells us whether users are current and 22 | // are 18F employees. We'll use that to filter to just relevant users. 23 | const userDataBody = await getFromTock(`/user_data.json`); 24 | 25 | // Filter only current 18F employees. Only keep the user property. This 26 | // is their username, and we'll use that to filter the later user list. 27 | const userDataObjs = userDataBody 28 | .filter((u) => u.is_active && u.current_employee && u.is_18f_employee) 29 | .map((u) => u.user); 30 | 31 | // Now get the list of users. This includes email addresses, which we can 32 | // use to associate a user to a Slack account. However, this doesn't tell 33 | // us whether they are currently an employee or with 18F, so we have to 34 | // combine these two lists together. 35 | const usersBody = await getFromTock(`/users.json`); 36 | 37 | // Keep just the bits we care about. 38 | const users = usersBody 39 | .filter((u) => userDataObjs.includes(u.username)) 40 | .map((u) => ({ 41 | user: u.username, 42 | email: u.email, 43 | tock_id: u.id, 44 | })); 45 | 46 | return users; 47 | }; 48 | 49 | /** 50 | * Get the 18F users who have not recorded their time in Tock for a given time 51 | * period. 52 | * @async 53 | * @param {Object} now Moment object representing the current time. 54 | * @param {Number} weeksAgo How many weeks in the past to check. Defaults to 1. 55 | * @returns {>} The list of users who have not Tocked 56 | */ 57 | const get18FUsersWhoHaveNotTocked = async (now, weeksAgo = 1) => { 58 | while (now.format("dddd") !== "Sunday") { 59 | now.subtract(1, "day"); 60 | } 61 | // We're now at the nearest past Sunday, but that's the start of the 62 | // current reporting period. Now back up the appropriate number of weeks. 63 | now.subtract(7 * weeksAgo, "days"); 64 | 65 | const reportingPeriodStart = now.format("YYYY-MM-DD"); 66 | 67 | const tockUsers = await getCurrent18FTockUsers(); 68 | 69 | const allUnTockedUsers = await getFromTock( 70 | `/reporting_period_audit/${reportingPeriodStart}.json`, 71 | ); 72 | 73 | return allUnTockedUsers.filter((user) => 74 | tockUsers.some((tockUser) => tockUser.tock_id === user.id), 75 | ); 76 | }; 77 | 78 | /** 79 | * Get all current 18F Tock users that are also Slack users. 80 | * @async 81 | * @returns {Promise>} A list of users that are both current 18F 82 | * employees in Tock and users in Slack, joined on their email addresses. 83 | */ 84 | const get18FTockSlackUsers = async () => { 85 | const allSlackUsers = await getSlackUsers(); 86 | 87 | // This shouldn't filter anyone who would be in the current 18F Tock users, 88 | // but there's no good reason we can't go ahead and do this filter to be safe. 89 | const slackUsers = allSlackUsers 90 | .filter((u) => !u.is_restricted && !u.is_bot && !u.deleted) 91 | .map((u) => ({ 92 | slack_id: u.id, 93 | name: u.real_name, 94 | email: u.profile.email, 95 | tz: u.tz, 96 | })); 97 | 98 | const tockUsers = await getCurrent18FTockUsers(); 99 | 100 | const tockSlackUsers = tockUsers 101 | .filter((tock) => 102 | slackUsers.some( 103 | (slackUser) => 104 | slackUser.email?.toLowerCase() === tock.email?.toLowerCase(), 105 | ), 106 | ) 107 | .map((tock) => ({ 108 | ...tock, 109 | ...slackUsers.find( 110 | (slackUser) => 111 | slackUser.email?.toLowerCase() === tock.email?.toLowerCase(), 112 | ), 113 | })); 114 | 115 | return tockSlackUsers; 116 | }; 117 | 118 | module.exports = { 119 | getCurrent18FTockUsers, 120 | get18FUsersWhoHaveNotTocked, 121 | get18FTockSlackUsers, 122 | }; 123 | -------------------------------------------------------------------------------- /src/utils/tock.test.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment"); 2 | require("./test"); 3 | 4 | describe("utils / tock", () => { 5 | const getSlackUsers = jest.fn(); 6 | jest.doMock("./slack", () => ({ getSlackUsers })); 7 | 8 | process.env.TOCK_API = "https://tock"; 9 | process.env.TOCK_TOKEN = "tock token"; 10 | 11 | const { 12 | get18FTockSlackUsers, 13 | get18FUsersWhoHaveNotTocked, 14 | getCurrent18FTockUsers, 15 | } = require("./tock"); // eslint-disable-line global-require 16 | 17 | getSlackUsers.mockResolvedValue([ 18 | { 19 | deleted: false, 20 | id: "slack 1", 21 | is_bot: false, 22 | is_restricted: false, 23 | profile: { email: "EmAiL 1" }, 24 | real_name: "user 1", 25 | tz: "timezone 1", 26 | }, 27 | { 28 | deleted: false, 29 | id: "slack 2", 30 | is_bot: false, 31 | is_restricted: true, 32 | profile: { email: "email 2" }, 33 | real_name: "user 2", 34 | tz: "timezone 2", 35 | }, 36 | { 37 | deleted: false, 38 | id: "slack 3", 39 | is_bot: true, 40 | is_restricted: false, 41 | profile: { email: "email 3" }, 42 | real_name: "user 3", 43 | tz: "timezone 3", 44 | }, 45 | { 46 | deleted: true, 47 | id: "slack 4", 48 | is_bot: false, 49 | is_restricted: false, 50 | profile: { email: "email 4" }, 51 | real_name: "user 4", 52 | tz: "timezone 4", 53 | }, 54 | { 55 | deleted: false, 56 | id: "slack 5", 57 | is_bot: false, 58 | is_restricted: false, 59 | profile: { email: "email 5" }, 60 | real_name: "user 5", 61 | tz: "timezone 5", 62 | }, 63 | ]); 64 | 65 | fetch.mockImplementation(async (url) => { 66 | const json = jest.fn(); 67 | 68 | switch (url.toString()) { 69 | case "https://tock/user_data.json": 70 | json.mockResolvedValue([ 71 | { 72 | is_18f_employee: true, 73 | is_active: true, 74 | current_employee: true, 75 | user: "user 1", 76 | }, 77 | { 78 | is_18f_employee: false, 79 | is_active: true, 80 | current_employee: true, 81 | user: "user 2", 82 | }, 83 | { 84 | is_18f_employee: true, 85 | is_active: false, 86 | current_employee: true, 87 | user: "user 3", 88 | }, 89 | { 90 | is_18f_employee: true, 91 | is_active: true, 92 | current_employee: false, 93 | user: "user 4", 94 | }, 95 | { 96 | is_18f_employee: true, 97 | is_active: true, 98 | current_employee: true, 99 | user: "user 5", 100 | }, 101 | ]); 102 | break; 103 | 104 | case "https://tock/users.json": 105 | json.mockResolvedValue([ 106 | { email: "email 1", id: 1, username: "user 1" }, 107 | { email: "email 2", id: 2, username: "user 2" }, 108 | { email: "email 3", id: 3, username: "user 3" }, 109 | { email: "email 4", id: 4, username: "user 4" }, 110 | { email: "email 5", id: 5, username: "user 5" }, 111 | ]); 112 | break; 113 | 114 | case "https://tock/reporting_period_audit/2020-10-04.json": 115 | json.mockResolvedValue([ 116 | { 117 | id: 1, 118 | username: "user 1", 119 | email: "email 1", 120 | }, 121 | ]); 122 | break; 123 | 124 | case "https://tock/reporting_period_audit/2020-10-11.json": 125 | json.mockResolvedValue([ 126 | { 127 | id: 5, 128 | username: "user 5", 129 | email: "email 5", 130 | }, 131 | ]); 132 | break; 133 | 134 | default: 135 | throw new Error(`unmocked url: ${url}`); 136 | } 137 | 138 | return { json }; 139 | }); 140 | 141 | it("gets a list of current 18F Tock users", async () => { 142 | const users = await getCurrent18FTockUsers(); 143 | 144 | expect(users).toEqual([ 145 | { user: "user 1", email: "email 1", tock_id: 1 }, 146 | { user: "user 5", email: "email 5", tock_id: 5 }, 147 | ]); 148 | }); 149 | 150 | it("gets a list of 18F Tock users and their associated Slack details", async () => { 151 | const users = await get18FTockSlackUsers(); 152 | 153 | expect(users).toEqual([ 154 | { 155 | // Canonical email address is taken from Slack. The Slack mock data has 156 | // this email address in mixed caps, so that's what we expect to see. 157 | email: "EmAiL 1", 158 | name: "user 1", 159 | slack_id: "slack 1", 160 | tock_id: 1, 161 | user: "user 1", 162 | tz: "timezone 1", 163 | }, 164 | { 165 | email: "email 5", 166 | name: "user 5", 167 | slack_id: "slack 5", 168 | tock_id: 5, 169 | user: "user 5", 170 | tz: "timezone 5", 171 | }, 172 | ]); 173 | }); 174 | 175 | describe("gets a list of 18F Tock users who have not Tocked", () => { 176 | beforeAll(() => { 177 | jest.useFakeTimers(); 178 | }); 179 | 180 | beforeEach(() => { 181 | jest.setSystemTime(0); 182 | }); 183 | 184 | afterAll(() => { 185 | jest.useRealTimers(); 186 | }); 187 | 188 | it("defaults to looking at the reporting period from a week ago", async () => { 189 | const date = moment("2020-10-14"); 190 | jest.setSystemTime(date.toDate()); 191 | const users = await get18FUsersWhoHaveNotTocked(date); 192 | 193 | // Only user 1 has not reported in the previous period. 194 | expect(users).toEqual([{ id: 1, email: "email 1", username: "user 1" }]); 195 | }); 196 | 197 | it("defaults to looking at the reporting period from this week", async () => { 198 | const date = moment("2020-10-14"); 199 | jest.setSystemTime(date.toDate()); 200 | const users = await get18FUsersWhoHaveNotTocked(date, 0); 201 | 202 | // Only user 5 has not reported in the current period. 203 | expect(users).toEqual([{ id: 5, email: "email 5", username: "user 5" }]); 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /sync-inclusion-bot-words.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs/promises"); 2 | const jsYaml = require("js-yaml"); 3 | 4 | // The frontmatter here is all of the comments at the beginning of the file. 5 | // Once there's a non-comment line, the frontmatter ends. 6 | const getYamlFrontmatter = (yaml) => { 7 | const firstNonComment = yaml 8 | .split("\n") 9 | .findIndex((line) => !line.startsWith("#")); 10 | const frontmatter = yaml.split("\n").slice(0, firstNonComment).join("\n"); 11 | 12 | return frontmatter; 13 | }; 14 | 15 | // The parsing works by finding lines that start with a vertical pipe, a 16 | // space, and anything other than a dash. The vertical pipe represents the 17 | // start of a markdown table row, which we want, and the dash indicates 18 | // that what we're seeing is actually the divider between the table header 19 | // and the table body. 20 | const getMarkdownTableRows = (md) => 21 | md.match(/\| [^-].+\|/gi).map((v) => v.split("|")); 22 | 23 | // Rows are split along the vertical pipes, which gives us an empty string 24 | // before the pipe, so given the whole row, we'll just ignore the first item. 25 | const markdownRowToTrigger = ([, matches, alternatives, why]) => ({ 26 | // Trigger matches are separated by commas, so we'll split them along 27 | // commas and trim any remaining whitespace 28 | matches: matches.split(",").map((v) => v.trim().toLowerCase()), 29 | 30 | // Suggested alternatives are separated by line breaks, because they can 31 | // include punctuation, including commas. Similarly split and trim. 32 | alternatives: alternatives.split("
").map((v) => v.trim()), 33 | 34 | // The "why" text can contain some extra reading, after a pair of line 35 | // breaks, but the bot shouldn't show that because it's too much content 36 | // to display. So we strip that out. The "why" also uses "this term" in 37 | // most places to refer to the triggering phrase, but the bot is capable 38 | // of showing the user exactly what word triggered its response. So we 39 | // replace "this term" with the ":TERM:" token (in quotes, so it will be 40 | // quoted when the user sees it) to let the bot do that. 41 | why: why 42 | .trim() 43 | .replace(/
.*/, "") 44 | .replace(/this term/i, '":TERM:"'), 45 | }); 46 | 47 | const getTriggerIgnoreMap = (currentConfig) => { 48 | const triggerWithIgnore = (newTrigger) => { 49 | // Find a trigger in the existing config that has at least one of the same 50 | // matches as the new trigger. There may not be one, but if... 51 | const existing = currentConfig.triggers.find( 52 | ({ matches: currentMatches }) => 53 | currentMatches.some((v) => newTrigger.matches.includes(v)), 54 | ); 55 | 56 | // If there is an existing config that matches AND it has an ignore property 57 | // return a new trigger that includes the ignore property. (We do it this 58 | // way instead of using an object spread so we get the properties in the 59 | // order we want for readability when it gets written to yaml.) 60 | if (existing?.ignore) { 61 | return { 62 | matches: newTrigger.matches, 63 | alternatives: newTrigger.alternatives, 64 | ignore: existing.ignore, 65 | why: newTrigger.why, 66 | }; 67 | } 68 | 69 | // Otherwise just return what we got. 70 | return newTrigger; 71 | }; 72 | return triggerWithIgnore; 73 | }; 74 | 75 | const main = async () => { 76 | // Load the current config yaml and prase it into Javascript 77 | const currentYamlStr = await fs.readFile("src/scripts/inclusion-bot.yaml", { 78 | encoding: "utf-8", 79 | }); 80 | const currentConfig = jsYaml.load(currentYamlStr, { json: true }); 81 | 82 | // Also find the frontmatter comments so we can preserve it. 83 | const frontmatter = getYamlFrontmatter(currentYamlStr); 84 | 85 | const triggerWithIgnore = getTriggerIgnoreMap(currentConfig); 86 | 87 | // Read and parse the markdown. 88 | const mdf = await fs.readFile("InclusionBot.md", { encoding: "utf-8" }); 89 | const md = { 90 | // Preserve the link and message properties from the current config... 91 | link: currentConfig.link, 92 | message: currentConfig.message, 93 | 94 | // ...but rebuild the triggers from the markdown. 95 | triggers: getMarkdownTableRows(mdf) 96 | // Turn each row into a trigger object. 97 | .map(markdownRowToTrigger) 98 | 99 | // Add an ignore property if there's a corresponding trigger in the 100 | // existing config that has an ignore property. This is how we make sure 101 | // we don't lose those when the new config is built. 102 | .map(triggerWithIgnore) 103 | 104 | // And remove the "triggers" that include "instead of", which is actually 105 | // the table header. 106 | .filter(({ matches }) => !/^instead of/i.test(matches.join(","))), 107 | }; 108 | 109 | // Sort the triggers by their first matches. This is meant to make it easier 110 | // for folks to find what they're looking for if they need to manually tweak 111 | // the yaml file. 112 | md.triggers.sort(({ matches: aa }, { matches: bb }) => { 113 | // Get the Unicode numeric value for the first character of the first match 114 | // in each of the triggers we're comparing. 115 | const a = aa[0].toLowerCase().charCodeAt(0); 116 | const b = bb[0].toLowerCase().charCodeAt(0); 117 | 118 | // Now we can just do math! 119 | return a - b; 120 | }); 121 | 122 | // Parse the object back into a yaml string 123 | const configYaml = jsYaml 124 | .dump(md) 125 | // Put in some newlines between things, so it looks nicer. 126 | .replace(/\n([a-z]+):/gi, "\n\n$1:") 127 | .replace(/ {2}- matches/gi, "\n - matches"); 128 | 129 | // And write that puppy to disk 130 | await fs.writeFile( 131 | "src/scripts/inclusion-bot.yaml", 132 | [frontmatter, "", configYaml].join("\n"), 133 | { encoding: "utf-8" }, 134 | ); 135 | }; 136 | 137 | main(); 138 | --------------------------------------------------------------------------------