Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

It's aliiiiiiive! CiviBot lives! #2

Merged
merged 6 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/format.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Format

on: [pull_request]

# 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: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '18'

- name: Install dependencies
run: npm install

- 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 '^$@$$'
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,43 @@
# 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.

You will need node version 18+. This is currently running on 18, but probably works fine with later versions.

Run `npm install` from the root of the repo to install dependencies.
Run `make fmt` to format your code before submitting a PR.

After merging a PR, run `!deploy latest` in #civibot-admin to deploy your changes.

### 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)
})
Loading
Loading