Skip to content

Commit

Permalink
It's aliiiiiiive! CiviBot lives!
Browse files Browse the repository at this point in the history
  • Loading branch information
nb1701 committed Sep 24, 2024
1 parent 46d364a commit 7983623
Show file tree
Hide file tree
Showing 22 changed files with 6,101 additions and 130 deletions.
25 changes: 25 additions & 0 deletions .github/workflows/format.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Format

on:
pull_request:
branches-ignore:
- 'spike/**'

# cancels in-progress jobs on this pull request
# avoids wasted work when a new commit is pushed
concurrency:
group: format-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions: read-all

jobs:
formatting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Run formatter
run: npm run lint
130 changes: 1 addition & 129 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,130 +1,2 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*

# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage
*.lcov

# nyc test coverage
.nyc_output

# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# Snowpack dependency directory (https://snowpack.dev/)
web_modules/

# TypeScript cache
*.tsbuildinfo

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache

# Next.js build output
.next
out

# Nuxt.js build / generate output
.nuxt
dist

# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public

# vuepress build output
.vuepress/dist

# vuepress v2.x temp and cache directory
.temp
.cache

# Docusaurus cache and generated files
.docusaurus

# Serverless directories
.serverless/

# FuseBox cache
.fusebox/

# DynamoDB Local files
.dynamodb/

# TernJS port file
.tern-port

# Stores VSCode versions used for testing VSCode extensions
.vscode-test

# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
brain.json
9 changes: 9 additions & 0 deletions .prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
module.exports = {
singleQuote: true,
trailingComma: 'all',
arrowParens: 'always',
semi: false,
bracketSpacing: false,
tabWidth: 2,
useTabs: false,
}
47 changes: 47 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
SHELL:=/bin/bash
PATH:=node_modules/.bin:$(PATH)

SYSCONFDIR=$(PREFIX)/etc
VARDIR=$(PREFIX)/var
INITDIR=$(SYSCONFDIR)/systemd/system
CIVIBOTDIR=/home/civibot/civibot
LOGDIR=/home/civibot/logs
BACKUPDIR=/home/civibot/brain_backups
INSTALL=/bin/install -p

fmt:
npm run lint:fix

node_modules:
test -d node_modules || npm install

install:
npm install
# Remove any cruft not stored in git
# git clean -d -f
sudo mkdir -p $(LOGDIR) $(BACKUPDIR) $(SYSCONFDIR)/civibot $(INITDIR) $(SYSCONFDIR)/logrotate.d $(SYSCONFDIR)/cron.hourly
sudo chown civibot:civibot $(LOGDIR)
sudo chown civibot:civibot $(BACKUPDIR)
sudo /bin/cp -p $(CIVIBOTDIR)/files/civibot.service $(INITDIR)
sudo /bin/systemctl daemon-reload
sudo /bin/cp -sf $(CIVIBOTDIR)/files/civibot.logrotate $(SYSCONFDIR)/logrotate.d
sudo /bin/cp -sf $(CIVIBOTDIR)/files/backup-brain.sh $(SYSCONFDIR)/cron.hourly
sudo /bin/systemctl enable civibot.service
sudo /bin/systemctl restart civibot.service

uninstall:
# This only removes the service, logrotate, and cronjob, not the app files
sudo /bin/systemctl stop civibot.service
sudo /bin/systemctl disable civibot.service
sudo rm -f $(INITDIR)/civibot.service
sudo rm -f $(SYSCONFDIR)/logrotate.d/civibot.logrotate
sudo rm -f $(SYSCONFDIR)/cron.hourly/backup-brain.sh
sudo /bin/systemctl daemon-reload

restart:
sudo /bin/systemctl restart civibot.service

.PHONY: list
list:
# This horrible thing lists user-defined targets of the Makefile
@$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$'
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,38 @@
# civibot
Slack app for the CiviForm workspace
Slack app for the CiviForm workspace. This is designed to be run in a specific EC2 instance.

## Setup
### Prerequisites
1. EC2 instance with Ubuntu 24.04 (probably works fine on others, but this is what it's using now).
2. Create a user called `civibot` with sudo access, using /bin/bash for the shell.
3. Install make, nodejs, npm, git, unzip.
4. Install the [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html)
5. In IAM (not IAM Identity Center), create a `civibot` user. Create an access key for the user.
6. On the host, run `aws configure`, providing the access key and secret.
7. Requires `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, and `SLACK_APP_TOKEN` secrets in AWS Secrets Manager.
a. Each secret should have the given name, and the actual key in the secret should also be the given name, with the value being the Slack secret value. It should also have a tag called `name` with the value being the secret name.
b. Create a policy in AWS that allows the `secretsmanager:GetSecretValue` permission on the ARNs for the three secrets (note that the ARN looks like `arn:aws:secretsmanager:us-east-1:<account id>:secret:SLACK_BOT_TOKEN-<random string>` so you will probably want to use `SLACK_BOT_TOKEN-*` in the resource definition), as well as `secretsmanager:ListSecrets` for all resources (`"*"`).
c. Apply the policy to the `civibot` user.
8. The civibot_github key should be in `/home/civibot/.ssh` with mode `0600`. You can get this key from Nick.
9. `/home/civibot/.ssh/config` should have the following contents:
```
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/civibot_github
IdentitiesOnly yes
```

### Running
1. Clone the repo.
2. Run `make install`. This will install a service that runs the bot.
3. Tail the log in `/home/civibot/civibot/logs/civibot.log` to ensure there are no errors.

## Development
You can deploy a different branch of the civibot repo by using the `!deploy` command. You must be in either #civibot-admin or #civibot-test to do this, and must be on the list of CiviBot admins. If you get things stuck with the app unable to start on the new branch, contact Nick to SSH into the node and fix it (or do so yourself if you have the SSH key). There is also a `!restart` command in case things get weird, but it's still responding to commands.

Run `make fmt` to format your code before submitting a PR.

### Tips
* If you want to be able to have CiviBot respond in whatever context it was triggered in (channel, DM, thread), use `context.say` instead of just `say`. If you need to respond with custom blocks (see the xkcd script), use the regular `say`.
* Create a `help` hash that maps command names to help text. Then, export both this and a `setup` function that does the actual meat of the script. These two things are automatically loaded by the app.
109 changes: 109 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
const {App} = require('@slack/bolt')
const fs = require('fs')
const path = require('path')
const {loadBrain} = require('./utils/brain.js')
const {ADMIN_ROOMS} = require('./utils/constants.js')
const process = require('process')
const {exec} = require('child_process')

let secrets = {
SLACK_BOT_TOKEN: '',
SLACK_SIGNING_SECRET: '',
SLACK_APP_TOKEN: '',
}

// This is used to prevent querying for all users on startup,
// since doing this frequently during development will get
// you rate limited.
let SKIP_USER_LOAD = true

async function loadAllSecrets() {
const {SecretsManagerClient, ListSecretsCommand, GetSecretValueCommand} =
await import('@aws-sdk/client-secrets-manager')
const secretsManager = new SecretsManagerClient({region: 'us-east-1'})
const listResponse = await secretsManager.send(new ListSecretsCommand({}))
const secretArns = listResponse.SecretList
const secretPromises = Object.keys(secrets).map(async (secret) => {
const secretArn = secretArns.find((s) =>
s.Tags.some((tag) => tag.Key === 'name' && tag.Value === secret),
)
if (!secretArn) {
throw new Error(`Secret ${secret} not found`)
}

const value = await secretsManager.send(
new GetSecretValueCommand({SecretId: secretArn.ARN}),
)
secrets[secret] = JSON.parse(value.SecretString)[secret]
console.log(`Loaded secret ${secret}`)
})
await Promise.all(secretPromises)
}

async function startApp() {
await loadAllSecrets()

// Load the brain from disk on startup
loadBrain()

// Create the app
const app = new App({
token: secrets.SLACK_BOT_TOKEN,
signingSecret: secrets.SLACK_SIGNING_SECRET,
socketMode: true,
appToken: secrets.SLACK_APP_TOKEN,
port: process.env.PORT || 30000,
})

// Middleware to ensure we always provide thread_ts when it exists
// so we respond in the appropriate way.
app.use(async ({message, context, say, next}) => {
context.say = async (text) => {
if (text) {
await say({text: text, thread_ts: message.thread_ts})
}
}
await next()
})

// Load all users on startup, then listen for new user events
if (!SKIP_USER_LOAD) {
require('./utils/users.js')(app, secrets.SLACK_BOT_TOKEN)
}

// Load scripts
const scriptsPath = path.join(__dirname, 'scripts')
fs.readdirSync(scriptsPath).forEach((file) => {
if (file.endsWith('.js')) {
const scriptModule = require(path.join(scriptsPath, file))
if (scriptModule.setup) {
scriptModule.setup(app)
}
}
})

let rev
exec('git rev-parse HEAD', (err, stdout) => {
if (err) {
console.error(`Error executing git rev-parse: ${err}`)
rev = 'unknown'
} else {
rev = stdout.trim()
}
})

await app.start()

for (const room of ADMIN_ROOMS) {
await app.client.chat.postMessage({
token: secrets.SLACK_BOT_TOKEN,
channel: room,
text: '⚡️ CiviBot is running at revision ' + rev,
})
}
console.log('⚡️ CiviBot is running at revision ' + rev)
}

startApp().catch((error) => {
console.error('Error:', error)
})
30 changes: 30 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const prettierConfig = require('eslint-config-prettier')
const prettierPlugin = require('eslint-plugin-prettier')
const {node} = require('globals')

module.exports = [
{
ignores: ['node_modules'],
},
{
files: ['**/*.js'],
plugins: {
prettier: prettierPlugin,
},
rules: {
'prettier/prettier': 'error',
'no-unused-vars': 'warn',
},
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'script',
globals: {
...node,
},
},
},
{
files: ['**/*.js'],
...prettierConfig,
},
]
Loading

0 comments on commit 7983623

Please sign in to comment.