Slack karma service implemented in Go, designed as a long-running container.
- Listens to Slack Events API
messageandapp_mentionevents and applies karma. - Supports
+and-runs with discord-karma parity rules (min 2 symbols, cap at 6 symbols => max delta 5). - Slash commands:
/leaderboard/settings reply_mode thread|channel
- DynamoDB storage with workspace (
team_id) isolation.
PORT(default:8080)AWS_REGION(default:ap-southeast-2)AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(DynamoDB credentials; standard AWS SDK chain)DYNAMODB_ENDPOINT(optional; set tohttp://localhost:8000for DynamoDB Local, leave unset in prod)DYNAMODB_KARMA_TABLE(default:plusplus_karma)DYNAMODB_SETTINGS_TABLE(default:plusplus_channel_settings)DYNAMODB_WORKSPACES_TABLE(default:plusplus_workspaces)MAX_KARMA_PER_ACTION(default:5)SLACK_SIGNING_SECRET(required for real Slack traffic)SLACK_BOT_TOKEN(single-workspace / dev only; used to post via the Slack Web API when OAuth is not configured). WhenSLACK_CLIENT_IDis set (multi-tenant OAuth), per-workspace tokens are authoritative and this value is ignored.PUBLIC_BASE_URL(required whenSLACK_CLIENT_IDis set; fixes the OAuthredirect_urito a trusted origin instead of inferring it from request headers)
Tables are created automatically on startup (on-demand / pay-per-request billing) if they do not already exist. The IAM principal therefore needs dynamodb:CreateTable and dynamodb:DescribeTable in addition to the item operations (see deployment section).
- Go 1.24+
- Docker + Docker Compose
make upThis starts:
dynamodb(DynamoDB Local) onlocalhost:8000(in-memory)- app on
localhost:8080(tables auto-created on boot)
make downmake runmake fmt
make lint
make test
make test-integration- Create a Slack app and install it to your workspace.
- Add bot token scopes:
app_mentions:readchannels:historygroups:historychat:writecommandsusergroups:readusers:read
- Configure Event Subscriptions:
- Request URL:
https://<public-url>/slack/events - Subscribe to bot events:
app_mentionmessage.channelsmessage.groups(private channels)
- Request URL:
- Configure Slash Commands:
/leaderboardRequest URL:https://<public-url>/slack/commands/settingsRequest URL:https://<public-url>/slack/commands
- Set local env vars:
SLACK_SIGNING_SECRETSLACK_BOT_TOKEN
For local callbacks, use a tunnel (for example ngrok):
ngrok http 8080Want to run your own instance? Published multi-arch container images
(ghcr.io/jordan-simonovski/plusplus) are built on every push to main. See
docs/self-hosting.md for the full operator guide:
required environment variables, the image name and tags, and DynamoDB table
setup (let the app create them, or pre-create with least-privilege IAM).
- Storage is DynamoDB. Three tables (karma, channel settings, workspaces) use on-demand (pay-per-request) billing, so there is no idle cost — you pay per read/write.
- In production the tables are provisioned by Terraform (see Infrastructure). The app is describe-first: it verifies tables exist on boot and only creates them when missing (local dev / DynamoDB Local). So the production runtime credentials need
dynamodb:DescribeTablebut notCreateTable. - The app authenticates with the standard AWS SDK credential chain. On Railway that means
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_REGIONenv vars. - Leave
DYNAMODB_ENDPOINTunset in production (it is only for DynamoDB Local).
AWS's best practice is to avoid long-lived access keys and use temporary credentials. For a workload running outside AWS, the "correct" path is IAM Roles Anywhere (X.509 certs from a private CA → short-lived STS credentials via a credential helper). It is the right answer at scale, but it requires standing up a private CA and running the credential helper inside the container — overkill for a single Slack bot.
The pragmatic, widely-used approach for Railway → DynamoDB is a dedicated IAM user with a least-privilege policy and static keys stored as Railway secrets. The AWS SDK picks these up automatically. If you later want temporary credentials, swap the IAM user for Roles Anywhere without touching application code (the SDK credential chain handles both).
The Terraform bootstrap config (see Infrastructure) creates a least-privilege IAM user plusplus-runtime with exactly this policy — describe + item ops, scoped to the three tables and the leaderboard index:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DescribeTables",
"Effect": "Allow",
"Action": ["dynamodb:DescribeTable"],
"Resource": [
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_karma",
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_channel_settings",
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_workspaces"
]
},
{
"Sid": "ItemOps",
"Effect": "Allow",
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:Query"],
"Resource": [
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_karma",
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_karma/index/*",
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_channel_settings",
"arn:aws:dynamodb:REGION:ACCOUNT_ID:table/plusplus_workspaces"
]
}
]
}Create an access key for that user (aws iam create-access-key --user-name plusplus-runtime) and rotate it periodically. There is no CreateTable because Terraform owns the tables.
- Create a new Railway project and add a service from this repository.
- Use the default container entrypoint (no custom command needed with the
Dockerfile). - Ensure Railway exposes port
8080.
Set these in Railway service variables:
PORT=8080AWS_REGION=<your region, e.g. ap-southeast-2>AWS_ACCESS_KEY_ID=<iam user access key id>AWS_SECRET_ACCESS_KEY=<iam user secret>MAX_KARMA_PER_ACTION=5SLACK_SIGNING_SECRET=<from slack app settings>SLACK_BOT_TOKEN=<xoxb token>
For multi-workspace OAuth install (the /slack/install flow), also set:
SLACK_CLIENT_ID=<from slack app settings>SLACK_CLIENT_SECRET=<from slack app settings>TOKEN_ENCRYPTION_KEY=<base64 32-byte key>PUBLIC_BASE_URL=https://api.pluspluskarma.dev— pins generated OAuthredirect_uri/install URLs to your domain. If unset, the app infers the base URL from the requestHost. This value must exactly match a registered redirect URL in the Slack app manifest.
- Railway → service → Settings → Networking → Custom Domain → add
api.pluspluskarma.dev; Railway returns a CNAME target. - At your DNS provider for
pluspluskarma.dev, addCNAME api → <target>.up.railway.app. Railway auto-provisions TLS once DNS resolves. - Set
PUBLIC_BASE_URL=https://api.pluspluskarma.devand redeploy. - Apply the updated
slack-app-manifest.yamlso Slack's request/redirect URLs use the custom domain.
The one-time cmd/migrate tool copies the three tables (karma_totals, channel_settings, slack_workspaces) into DynamoDB. Bot-token ciphertext is copied as-is, so no encryption key is needed. It is idempotent (uses PutItem), so it is safe to re-run.
From a Supabase SQL export (recommended — no Postgres needed). Supabase's "Download backup" gives you a schema.sql and a data.sql (plain pg_dump). Point the tool at the data file; it parses the COPY blocks directly and ignores the auth.*/storage.* noise:
AWS_REGION=<region> AWS_ACCESS_KEY_ID=<id> AWS_SECRET_ACCESS_KEY=<secret> \
go run ./cmd/migrate data.sql(Do not try to restore schema.sql into a vanilla Postgres — it references Supabase-only roles and extensions and will error.)
From a live Postgres connection (if the old database is still up):
DATABASE_URL=<old supabase connection string> \
AWS_REGION=<region> AWS_ACCESS_KEY_ID=<id> AWS_SECRET_ACCESS_KEY=<secret> \
go run ./cmd/migrateOnce cutover is verified, cmd/migrate and the pgx dependency can be deleted.
After Railway deploys and you have a public URL (custom domain https://api.pluspluskarma.dev or the default https://<railway-domain>):
- Event Subscriptions request URL:
https://api.pluspluskarma.dev/slack/events /leaderboardcommand URL:https://api.pluspluskarma.dev/slack/commands/settingscommand URL:https://api.pluspluskarma.dev/slack/commands- OAuth redirect URL:
https://api.pluspluskarma.dev/slack/oauth/callback
- Hit
https://api.pluspluskarma.dev/healthzand confirmstatus: ok. - In Slack, run
/leaderboardand verify a response. - In a channel with the bot, send ambient events like
<@user> +++and verify persisted karma.
DynamoDB and the CI/runtime IAM are managed in terraform/, applied from GitHub Actions via OIDC (no long-lived AWS keys in the repo).
Layout:
terraform/bootstrap/— run once, locally, with admin credentials. Creates the S3 state bucket, the GitHub Actions OIDC provider, the CI role, and theplusplus-runtimeIAM user. Uses local state on purpose (it creates the bucket the main config then uses).terraform/— the DynamoDB tables. Uses the S3 backend and is applied by CI.
The two are split so that CI never has permission to modify its own IAM — IAM is bootstrapped by a human, CI only manages tables.
cd terraform/bootstrap
terraform init
terraform apply \
-var 'state_bucket_name=<globally-unique-bucket-name>' \
-var 'aws_region=ap-southeast-2'The GitHub OIDC provider is account-global (one per URL). If your account already has it, add -var 'create_github_oidc_provider=false' and the config will reference the existing one instead of creating a duplicate.
Note the outputs:
state_bucket_name→ put it interraform/backend.hcl(replaceREPLACE_WITH_STATE_BUCKET_NAME).ci_role_arn→ add as a GitHub repository variable namedAWS_ROLE_ARN(Settings → Secrets and variables → Actions → Variables).runtime_user_name→ create its access key for Railway:aws iam create-access-key --user-name plusplus-runtime.
.github/workflows/terraform.yml runs on changes under terraform/**:
- pull request →
fmt -check,init,validate,plan. - push to
main→ the above plusapply.
The CI role's trust policy only accepts this repo's main branch and pull requests. To apply locally instead:
cd terraform
terraform init -backend-config=backend.hcl
terraform applyThe
aws_dynamodb_tableresources intentionally use the deprecatedhash_key/range_keyarguments. The newerkey_schemablock triggers perpetual plan drift on GSIs (provider issue #46513); the deprecation warning is cosmetic and safe to ignore.