refactor(deploy): remove retired aws support

This commit is contained in:
2026-06-29 00:11:42 +07:00
parent 35be2c0acb
commit 5cabf4e1f4
48 changed files with 142 additions and 2918 deletions
+2 -11
View File
@@ -25,22 +25,13 @@ GEMINI_API_KEY=
# SOURCE_COMMIT (commit SHA) is read at startup for the deploynotify owner DM.
# Do NOT set it here. On Coolify (Docker Compose) it is a predefined variable
# that reaches the container only because docker-compose.yml references it
# that reaches the container only because compose.yml references it
# (`SOURCE_COMMIT: ${SOURCE_COMMIT:-}`) — Coolify supplies the value via
# --env-file. Local `docker compose up` has none, so deploynotify reports
# "unknown".
# ====================== Leave UNSET on self-host ==================
# These are AWS-only. cmd/server reads secrets directly from the plain env
# vars above; *_PARAMETER_NAME would force an SSM lookup that FAILS with no AWS
# credentials and bricks startup. Do NOT set any of them:
# TELEGRAM_BOT_TOKEN_PARAMETER_NAME
# TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME
# GEMINI_API_KEY_PARAMETER_NAME
# STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME
# GOLD_VNAPP_API_KEY_PARAMETER_NAME
#
# Also leave unset (defaults are correct for self-host):
# Defaults are correct for self-host:
# KV_PROVIDER — auto-selects mongodb because MONGO_URL is set
# PORT — defaults to 8080 (internal health server)
# TELEGRAM_WEBHOOK_SECRET — long polling has no webhook
+2 -14
View File
@@ -47,9 +47,8 @@ jobs:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
# No DB emulator step: storage integration tests (mongodb, dynamodb) skip
# gracefully when MONGODB_TEST_URL / DYNAMODB_LOCAL_URL are unset. Run them
# locally via `make test-mongo` / `make test-dynamodb`.
# No DB emulator step: MongoDB integration tests skip gracefully when
# MONGODB_TEST_URL is unset. Run them locally via `make test-mongo`.
- name: go test
env:
# Quiet test logs so real failures stand out.
@@ -64,14 +63,3 @@ jobs:
- name: docker build
run: docker build -t miti99bot .
iac:
name: SAM template validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: aws-actions/setup-sam@v3
with:
use-installer: true
- name: sam validate (offline)
run: sam validate --lint --region ap-southeast-1
-130
View File
@@ -1,130 +0,0 @@
name: deploy-aws
# RETIRED: miti99bot is self-hosted on Coolify + MongoDB Atlas (see
# docs/deploy-coolify-selfhosted.md). The AWS stack is decommissioned
# (docs/aws-decommission-runbook.md). The automatic push-to-main trigger is
# removed so a merge can never recreate the AWS stack. Kept as manual-only
# (workflow_dispatch) for reference; it requires the github-deploy-miti99bot
# OIDC role, which the decommission deletes — so a manual run fails until AWS
# is intentionally re-bootstrapped.
on:
workflow_dispatch:
permissions:
id-token: write # required for OIDC
contents: read
concurrency:
group: deploy-prod
cancel-in-progress: false
jobs:
deploy:
name: SAM deploy (prod)
runs-on: ubuntu-latest
env:
AWS_REGION: ap-southeast-1
STACK_NAME: miti99bot
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: '1.25'
cache: true
- uses: aws-actions/setup-sam@v3
with:
use-installer: true
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::225603493174:role/github-deploy-miti99bot
aws-region: ${{ env.AWS_REGION }}
- name: Build Lambda binary
run: make build-lambda
- name: SAM deploy
env:
ALERT_EMAIL: ${{ secrets.ALERT_EMAIL }}
STACK_ENV: prod
run: |
set -euo pipefail
# EventBridge Connection consumes ApiKeyValue at stack-update time
# and stores it in a service-linked secret. SSM holds the canonical
# value; fetch here and pass as a NoEcho CFN parameter so it never
# appears in template source or stack events.
CRON_SECRET=$(aws ssm get-parameter \
--name "/miti99bot/${STACK_ENV}/cron-shared-secret" \
--with-decryption --query Parameter.Value --output text)
echo "::add-mask::$CRON_SECRET"
# Non-secret CFN params. SAM CLI's --parameter-overrides REPLACES
# samconfig.toml's parameter_overrides (does not merge), so anything
# CI needs in the deployed stack must be listed here explicitly.
# Telegram user IDs are public (visible to anyone the bot DMs), so
# they live in this committed workflow rather than a secret.
# Keep this module list in sync with samconfig.toml's ModulesCSV.
OVERRIDES="CronSharedSecret=$CRON_SECRET BotOwnerID=1064111334 AdminUserIDs=1064111334 ModulesCSV=util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold,coin"
if [ -n "$ALERT_EMAIL" ]; then
OVERRIDES="$OVERRIDES AlertEmail=$ALERT_EMAIL"
fi
sam deploy --template-file template.yaml \
--no-confirm-changeset \
--no-fail-on-empty-changeset \
--parameter-overrides "$OVERRIDES"
- name: Smoke test (Function URL responds)
run: |
URL=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
--output text)
echo "FunctionUrl=$URL"
curl -fsSL --max-time 30 "$URL/" | tee /tmp/smoke.json | jq .
- name: Register Telegram webhook
env:
STACK_ENV: prod
run: |
set -euo pipefail
URL=$(aws cloudformation describe-stacks \
--stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
--output text)
TOKEN=$(aws ssm get-parameter \
--name "/miti99bot/${STACK_ENV}/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text)
echo "::add-mask::$TOKEN"
SECRET=$(aws ssm get-parameter \
--name "/miti99bot/${STACK_ENV}/telegram-webhook-secret" \
--with-decryption --query Parameter.Value --output text)
echo "::add-mask::$SECRET"
WEBHOOK_URL="${URL%/}/webhook"
echo "Setting Telegram webhook to ${WEBHOOK_URL}"
RESP=$(curl -fsS --max-time 30 -X POST \
"https://api.telegram.org/bot${TOKEN}/setWebhook" \
-d "url=${WEBHOOK_URL}" \
-d "secret_token=${SECRET}" \
-d 'allowed_updates=["message","callback_query"]')
echo "$RESP" | jq -e '.ok == true' >/dev/null \
|| { echo "setWebhook failed: $RESP"; exit 1; }
echo "$RESP" | jq '{ok, result, description}'
- name: Register Telegram command menu
env:
STACK_ENV: prod
run: |
set -euo pipefail
TOKEN=$(aws ssm get-parameter \
--name "/miti99bot/${STACK_ENV}/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text)
echo "::add-mask::$TOKEN"
echo "Registering Telegram commands from aws/telegram-commands.json"
RESP=$(curl -fsS --max-time 30 -X POST \
"https://api.telegram.org/bot${TOKEN}/setMyCommands" \
-H 'Content-Type: application/json' \
--data-binary "@aws/telegram-commands.json")
echo "$RESP" | jq -e '.ok == true' >/dev/null \
|| { echo "setMyCommands failed: $RESP"; exit 1; }
echo "$RESP" | jq '{ok, result, description}'
+21 -145
View File
@@ -1,42 +1,20 @@
.PHONY: help test test-dynamodb test-mongo dynamodb-local dynamodb-local-stop mongo-local mongo-local-stop vet build build-lambda run sam-validate sam-build sam-deploy telegram-setup telegram-webhook telegram-webhook-info telegram-commands telegram-commands-info telegram-commands-selfhost telegram-deletewebhook-selfhost telegram-webhook-info-selfhost migrate-dynamo-to-mongo migrate-verify logs clean
.PHONY: help test test-mongo mongo-local mongo-local-stop vet build run telegram-commands telegram-commands-info telegram-deletewebhook telegram-webhook-info clean
# Lambda target architecture. Match Globals.Architectures in template.yaml.
LAMBDA_GOOS ?= linux
LAMBDA_GOARCH ?= arm64
LAMBDA_OUT := build/lambda/bootstrap
# Short git SHA baked into the binary at link time. Consumed by
# internal/deploynotify to DM the owner once per new version. Falls back to
# empty string outside a git checkout (tarball, fresh clone without history)
# — deploynotify treats empty as "stay silent".
# Short git SHA baked into local binaries. Coolify sets SOURCE_COMMIT at
# runtime; this ldflags value is the fallback for local builds.
GIT_SHA := $(shell git rev-parse --short HEAD 2>/dev/null)
LDFLAGS := -s -w -X main.gitSHA=$(GIT_SHA)
# AWS deploy defaults. Override as needed:
# make telegram-webhook AWS_PROFILE=admin STACK_NAME=miti99bot STACK_ENV=prod
AWS_PROFILE ?= admin
STACK_NAME ?= miti99bot
STACK_ENV ?= prod
TELEGRAM_COMMANDS_FILE ?= aws/telegram-commands.json
TELEGRAM_COMMANDS_FILE ?= telegram-commands.json
help: ## Show this help
@grep -hE '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS=":.*?## "}; {printf " \033[36m%-22s\033[0m %s\n", $$1, $$2}'
# ---- Test ------------------------------------------------------------------
# Default: run unit tests that don't require any emulator.
test: ## Unit tests (no emulator required)
go test -race -count=1 ./...
# Run DynamoDB integration tests against DynamoDB Local.
# Override DDB_PORT if 8001 is taken on your host.
DDB_PORT ?= 8001
test-dynamodb: dynamodb-local mongo-local ## Run the DynamoDB→Mongo migrator e2e against local emulators
DYNAMODB_LOCAL_URL=http://localhost:$(DDB_PORT) \
MONGODB_TEST_URL=mongodb://127.0.0.1:$(MONGO_PORT) \
MONGO_DATABASE=migrate_test LOG_LEVEL=error \
go test -race -count=1 ./cmd/migrate-dynamo-to-mongo/...
# Run MongoDB integration tests against a local Mongo container.
# Override MONGO_PORT if 27017 is taken on your host.
MONGO_PORT ?= 27017
@@ -44,46 +22,22 @@ test-mongo: mongo-local ## Run MongoDB tests against a local Mongo container
MONGODB_TEST_URL=mongodb://127.0.0.1:$(MONGO_PORT) LOG_LEVEL=error \
go test -race -count=1 ./internal/storage/...
# ---- Lint / Vet -----------------------------------------------------------
# ---- Lint / Vet ------------------------------------------------------------
vet: ## go vet
go vet ./...
# ---- Build ----------------------------------------------------------------
# ---- Build -----------------------------------------------------------------
build: ## Build the local server binary (host arch)
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o ./bin/server ./cmd/server
build-lambda: ## Cross-compile bootstrap for Lambda (linux/arm64)
@mkdir -p $(dir $(LAMBDA_OUT))
CGO_ENABLED=0 GOOS=$(LAMBDA_GOOS) GOARCH=$(LAMBDA_GOARCH) \
go build -tags lambda.norpc -ldflags="$(LDFLAGS)" \
-o $(LAMBDA_OUT) ./cmd/server
@chmod +x $(LAMBDA_OUT)
@ls -lh $(LAMBDA_OUT) | awk '{print "lambda binary:", $$5}'
# ---- Run -------------------------------------------------------------------
# ---- Run ------------------------------------------------------------------
# Local dev run with an in-memory KV (no database needed).
run: ## Run locally (in-memory KV)
run: ## Run locally (in-memory storage unless MONGO_URL is set)
go run ./cmd/server
# ---- DynamoDB Local for tests ---------------------------------------------
dynamodb-local: ## Start DynamoDB Local container on :$(DDB_PORT) (idempotent)
@if ! docker ps --format '{{.Names}}' | grep -q '^miti99bot-ddb$$'; then \
docker run -d --rm --name miti99bot-ddb -p $(DDB_PORT):8000 \
amazon/dynamodb-local -jar DynamoDBLocal.jar -inMemory -sharedDb; \
echo "DynamoDB Local started on :$(DDB_PORT)"; \
sleep 1; \
else \
echo "DynamoDB Local already running"; \
fi
dynamodb-local-stop: ## Stop DynamoDB Local
-docker stop miti99bot-ddb
# ---- MongoDB local for tests ----------------------------------------------
# ---- MongoDB local for tests ------------------------------------------------
mongo-local: ## Start MongoDB container on :$(MONGO_PORT) (idempotent)
@if ! docker ps --format '{{.Names}}' | grep -q '^miti99bot-mongo$$'; then \
@@ -97,90 +51,9 @@ mongo-local: ## Start MongoDB container on :$(MONGO_PORT) (idempotent)
mongo-local-stop: ## Stop local MongoDB
-docker stop miti99bot-mongo
# ---- Data migration (DynamoDB → MongoDB Atlas) ----------------------------
# ---- Telegram operations ----------------------------------------------------
# Requires MONGO_URL + MONGO_DATABASE in the environment and AWS credentials
# for a READ-ONLY profile with dynamodb:Scan on the table. Use DRY_RUN=1 first.
# make migrate-dynamo-to-mongo DRY_RUN=1 MONGO_URL=… MONGO_DATABASE=…
# make migrate-dynamo-to-mongo MONGO_URL=… MONGO_DATABASE=…
# make migrate-verify MONGO_URL=… MONGO_DATABASE=…
MIGRATE_TABLE ?= miti99bot-data
migrate-dynamo-to-mongo: ## Copy DynamoDB → Mongo (DRY_RUN=1 for a dry run)
go run ./cmd/migrate-dynamo-to-mongo --dynamodb-table $(MIGRATE_TABLE) $(if $(DRY_RUN),--dry-run,)
migrate-verify: ## Verify per-module counts DynamoDB vs Mongo (exit non-zero on mismatch)
go run ./cmd/migrate-dynamo-to-mongo --dynamodb-table $(MIGRATE_TABLE) --verify
# ---- SAM (require AWS CLI + SAM CLI installed locally) -------------------
sam-validate: ## Validate template.yaml without contacting AWS
sam validate --lint
sam-build: build-lambda ## Produce the Lambda artifact (alias for build-lambda; sam deploy uses raw template directly)
@echo "Artifact ready at build/lambda/bootstrap; sam deploy --template-file template.yaml will zip it."
sam-deploy: build-lambda ## Deploy via SAM (uses samconfig.toml). Set ALERT_EMAIL=… optionally.
@if [ -n "$$ALERT_EMAIL" ]; then \
sam deploy --template-file template.yaml \
--no-confirm-changeset --no-fail-on-empty-changeset \
--parameter-overrides "AlertEmail=$$ALERT_EMAIL"; \
else \
sam deploy --template-file template.yaml \
--no-confirm-changeset --no-fail-on-empty-changeset; \
fi
telegram-setup: telegram-webhook telegram-commands ## Register Telegram webhook and command menu
telegram-webhook: ## Register Telegram webhook from stack FunctionUrl + SSM secrets
@set -eu; \
URL=$$(aws --profile "$(AWS_PROFILE)" cloudformation describe-stacks \
--stack-name "$(STACK_NAME)" \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" \
--output text); \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
SECRET=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-webhook-secret" \
--with-decryption --query Parameter.Value --output text); \
case "$$URL" in */) WEBHOOK_URL="$${URL}webhook" ;; *) WEBHOOK_URL="$${URL}/webhook" ;; esac; \
echo "Setting Telegram webhook to $$WEBHOOK_URL"; \
curl -sS -X POST "https://api.telegram.org/bot$${TOKEN}/setWebhook" \
-d "url=$${WEBHOOK_URL}" \
-d "secret_token=$${SECRET}" \
-d 'allowed_updates=["message","callback_query"]'; \
echo
telegram-webhook-info: ## Show Telegram getWebhookInfo using token from SSM
@set -eu; \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
curl -sS "https://api.telegram.org/bot$${TOKEN}/getWebhookInfo"; \
echo
telegram-commands: ## Register Telegram command menu from TELEGRAM_COMMANDS_FILE
@set -eu; \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
echo "Registering Telegram commands from $(TELEGRAM_COMMANDS_FILE)"; \
curl -sS -X POST "https://api.telegram.org/bot$${TOKEN}/setMyCommands" \
-H 'Content-Type: application/json' \
--data-binary "@$(TELEGRAM_COMMANDS_FILE)"; \
echo
telegram-commands-info: ## Show Telegram getMyCommands using token from SSM
@set -eu; \
TOKEN=$$(aws --profile "$(AWS_PROFILE)" ssm get-parameter \
--name "/miti99bot/$(STACK_ENV)/telegram-bot-token" \
--with-decryption --query Parameter.Value --output text); \
curl -sS "https://api.telegram.org/bot$${TOKEN}/getMyCommands"; \
echo
# ---- Telegram (self-host: token from TELEGRAM_BOT_TOKEN env, no AWS/SSM) ---
telegram-commands-selfhost: ## Register command menu using TELEGRAM_BOT_TOKEN env
telegram-commands: ## Register command menu using TELEGRAM_BOT_TOKEN env
@set -eu; \
: "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \
echo "Registering Telegram commands from $(TELEGRAM_COMMANDS_FILE)"; \
@@ -189,23 +62,26 @@ telegram-commands-selfhost: ## Register command menu using TELEGRAM_BOT_TOKEN en
--data-binary "@$(TELEGRAM_COMMANDS_FILE)"; \
echo
telegram-deletewebhook-selfhost: ## Cutover: delete the webhook so the poller can run (keeps buffered updates)
telegram-commands-info: ## Show Telegram getMyCommands using TELEGRAM_BOT_TOKEN env
@set -eu; \
: "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \
curl -sS "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/getMyCommands"; \
echo
telegram-deletewebhook: ## Delete webhook so the poller can run
@set -eu; \
: "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \
curl -sS -X POST "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/deleteWebhook" \
-d 'drop_pending_updates=false'; \
echo
telegram-webhook-info-selfhost: ## getWebhookInfo using TELEGRAM_BOT_TOKEN env (confirm url empty + pending draining)
telegram-webhook-info: ## Show Telegram getWebhookInfo using TELEGRAM_BOT_TOKEN env
@set -eu; \
: "$${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}"; \
curl -sS "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/getWebhookInfo"; \
echo
logs: ## Tail Lambda logs (last 5m). Override with SINCE=10m.
@sam logs --tail --stack-name miti99bot --start-time $${SINCE:-5m}ago
# ---- Clean ----------------------------------------------------------------
# ---- Clean -----------------------------------------------------------------
clean: ## Remove local build artifacts
rm -rf build/ bin/ .aws-sam/ cov.out
rm -rf build/ bin/ cov.out
+11 -13
View File
@@ -1,6 +1,7 @@
# miti99bot
Plug-n-play Telegram bot framework in Go. Self-hosted on Coolify + MongoDB Atlas via long polling and an in-process cron scheduler. (Previously ran on AWS Lambda + DynamoDB + EventBridge — now retired; see [`docs/deploy-aws.md`](docs/deploy-aws.md) for history.)
Plug-n-play Telegram bot framework in Go. Self-hosted on Coolify + MongoDB
Atlas via long polling and an in-process cron scheduler.
## Modules
@@ -17,28 +18,26 @@ Plug-n-play Telegram bot framework in Go. Self-hosted on Coolify + MongoDB Atlas
| `coin` | Crypto paper trading in USD (Binance -> Coinbase -> CoinGecko price fallback) |
| `stats` | `/stats` (top commands), `/stats users`, `/stats user <name>`, `/stats cmd <name>` |
Disable any module by editing `MODULES` in `template.yaml`.
Disable modules with the `MODULES` environment variable.
## Layout
```
cmd/server/ entrypoint (long polling + in-process cron + HTTP health)
cmd/migrate-dynamo-to-mongo/ one-off DynamoDB → MongoDB Atlas data migrator
internal/server/ HTTP route (/ health only; cron has no HTTP route)
internal/telegram/ Telegram long-polling bot wrapper
internal/cron/ in-process cron scheduler (replaces EventBridge)
internal/cron/ in-process cron scheduler
internal/modules/ Module framework, registry, dispatchers, modules
internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents
internal/ai/ Gemini client (used by twentyq)
compose.yml Coolify self-host stack (single bot service)
docs/deploy-coolify-selfhosted.md Self-host onboarding + cutover runbook
docs/aws-decommission-runbook.md AWS teardown (post-cutover)
template.yaml, aws/ Retired AWS SAM IaC + setup (kept for history)
telegram-commands.json Telegram command menu source
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
```
## Run locally
In-memory KV (no database required):
In-memory storage (no database required):
```sh
TELEGRAM_BOT_TOKEN=\
@@ -62,7 +61,6 @@ For integration tests (each skips when its emulator env var is unset):
```sh
make mongo-local # docker run mongo:7 on :27017
make test-mongo # internal/storage typed-store tests against local MongoDB
make test-dynamodb # DynamoDB→Mongo migrator e2e against DynamoDB Local + local Mongo
```
## Test
@@ -71,14 +69,14 @@ make test-dynamodb # DynamoDB→Mongo migrator e2e against DynamoDB Local
make vet # go vet
make test # full unit suite (no emulator)
make test-mongo # typed-store integration tests against local Mongo (requires Docker)
make test-dynamodb # migrator e2e against DynamoDB Local + Mongo (requires Docker)
```
## Deploy
**Self-host (current):** [`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) — Coolify + MongoDB Atlas (free M0), long polling (no public ingress), in-process cron. Storage auto-selects `mongodb` when `MONGO_URL` is set; the cron scheduler runs by default. Migrate existing data with [`cmd/migrate-dynamo-to-mongo`](cmd/migrate-dynamo-to-mongo/README.md), then tear down AWS via [`docs/aws-decommission-runbook.md`](docs/aws-decommission-runbook.md).
**AWS (retired):** [`docs/deploy-aws.md`](docs/deploy-aws.md) — kept for history.
[`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) covers
Coolify + MongoDB Atlas (free M0), long polling (no public ingress), and
in-process cron. Storage auto-selects `mongodb` when `MONGO_URL` is set; the
cron scheduler runs by default.
## License
-164
View File
@@ -1,164 +0,0 @@
# AWS account setup
One-time setup steps for a fresh AWS account. After this is done, every push to `main` deploys via GitHub Actions OIDC; no human-in-loop AWS commands needed.
> For the full onboarding walkthrough (prerequisites, Telegram wiring, cost guardrails), see [`../docs/deploy-aws-free-tier-guide.md`](../docs/deploy-aws-free-tier-guide.md). This file is the condensed cheatsheet.
> **Region:** `ap-southeast-1` (Singapore). Change in `samconfig.toml` if needed.
> **Stack name:** `miti99bot`. Change in `samconfig.toml`.
---
## 1. AWS account hygiene
1. Enable MFA on the root user.
2. Create an IAM admin user `admin` (CLI access keys). Use only for the first `sam deploy --guided`.
3. Set CLI default region:
```sh
aws configure set region ap-southeast-1 --profile admin
aws configure set aws_access_key_id AKIA… --profile admin
aws configure set aws_secret_access_key … --profile admin
```
## 2. SSM Parameter Store secrets
Create the four required secrets. **Names must match `template.yaml`** (`/miti99bot/${StackEnv}/…`).
```sh
aws ssm put-parameter --name /miti99bot/prod/telegram-bot-token \
--value "<bot-father-token>" --type SecureString --profile admin
aws ssm put-parameter --name /miti99bot/prod/telegram-webhook-secret \
--value "$(openssl rand -hex 32)" --type SecureString --profile admin
aws ssm put-parameter --name /miti99bot/prod/gemini-api-key \
--value "<google-ai-studio-key>" --type SecureString --profile admin
aws ssm put-parameter --name /miti99bot/prod/cron-shared-secret \
--value "$(openssl rand -hex 32)" --type SecureString --profile admin
```
Save the webhook + cron secrets locally — you'll set them on the Telegram side and on the EventBridge schedule headers.
## 3. GitHub OIDC identity provider
One-time per AWS account:
```sh
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
--profile admin
```
(GitHub publishes the canonical thumbprint; verify on docs.github.com if rotated.)
## 4. Deploy IAM role for GitHub Actions
Edit `aws/iam-github-oidc-trust.json` if you are changing the AWS account or GitHub repo. This repo is already prefilled for account `225603493174` and `tiennm99/miti99bot`, and the trust allowlist is narrowed to `refs/heads/main` only (see "Trust policy invariants" below). If you change accounts, update `.github/workflows/deploy.yml` to match the same role ARN, then:
```sh
aws iam create-role \
--role-name github-deploy-miti99bot \
--assume-role-policy-document file://aws/iam-github-oidc-trust.json \
--profile admin
# Permissions: stack-scoped inline policy committed at aws/iam-github-deploy-policy.json.
aws iam put-role-policy \
--role-name github-deploy-miti99bot \
--policy-name miti99bot-deploy \
--policy-document file://aws/iam-github-deploy-policy.json \
--profile admin
```
> Scoped to stacks/resources named `miti99bot*`. See [security audit](../plans/reports/code-reviewer-260518-1019-security-aws-infra.md) F1 for rationale and [plan](../plans/260518-1019-iam-least-privilege/) for the cutover record.
### Updating the deploy policy
When `template.yaml` adds a new resource type, the deploy role may need new IAM actions. Workflow:
1. Edit `aws/iam-github-deploy-policy.json` — add the action(s) + ARN pattern.
2. Apply out-of-band from a maintainer's `admin` profile (NOT via the workflow):
```sh
aws iam put-role-policy --role-name github-deploy-miti99bot \
--policy-name miti99bot-deploy \
--policy-document file://aws/iam-github-deploy-policy.json --profile admin
```
3. Commit the JSON. Next deploy uses the new permissions.
Drift check — structural compare, not byte-diff. `aws iam get-role-policy` returns JSON whose key ordering / whitespace differs from the local file but may be semantically identical. Compare normalized:
```sh
diff <(aws iam get-role-policy --role-name github-deploy-miti99bot \
--policy-name miti99bot-deploy --profile admin \
--query PolicyDocument | jq -S .) \
<(jq -S . aws/iam-github-deploy-policy.json)
```
Non-empty output = INVESTIGATE before reapplying. AWS-side may have been intentionally patched during an outage; blindly re-applying overwrites that fix.
### Trust policy invariants
`aws/iam-github-oidc-trust.json` constrains which GitHub Actions contexts can assume `github-deploy-miti99bot`. The current allowlist is intentionally narrow: only pushes to `main` can deploy.
**To add a new branch / context** (e.g., a future `dev` preview deploy):
1. Edit `aws/iam-github-oidc-trust.json` — add the new `sub` claim to the `StringLike` array. Examples:
- `repo:tiennm99/miti99bot:ref:refs/heads/dev` — pushes to `dev` branch
- `repo:tiennm99/miti99bot:environment:preview` — workflows scoped to a GitHub Environment named `preview` (requires `permissions: id-token: write`)
2. Apply out-of-band:
```sh
aws iam update-assume-role-policy --role-name github-deploy-miti99bot \
--policy-document file://aws/iam-github-oidc-trust.json --profile admin
```
3. Commit. Test by triggering the new workflow path.
**Reasons `pull_request` is NOT in the allowlist** (do not re-add without reviewing): PR-context OIDC tokens are derivable from any contributor's PR. Granting the deploy role to PRs is equivalent to granting deploy access to every contributor. Combined with the inline policy's IAM/Lambda/DynamoDB actions, an attacker-controlled PR could exfiltrate or alter prod state.
## 5. Add GitHub repo secrets
In GitHub repo settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
| `ALERT_EMAIL` (optional) | Email for the $1 budget alert |
The deploy workflow now uses the repo's fixed AWS account ID directly for the OIDC role ARN, so `AWS_ACCOUNT_ID` no longer needs to be stored in GitHub.
## 6. First deploy (manual)
```sh
make build-lambda
AWS_PROFILE=admin sam deploy --template-file template.yaml --guided
```
Confirm:
- Stack name: `miti99bot`
- Region: `ap-southeast-1`
- Capabilities: `CAPABILITY_IAM`
- Save to `samconfig.toml`: yes (already committed; this just confirms)
After `CREATE_COMPLETE`:
```sh
aws cloudformation describe-stacks --stack-name miti99bot \
--query "Stacks[0].Outputs" --output table --profile admin
```
Note the `FunctionUrl` — point the Telegram webhook at it (see [`../docs/deploy-aws-free-tier-guide.md`](../docs/deploy-aws-free-tier-guide.md) Step 5).
## 7. Tighten — optional but recommended
Once the first deploy succeeds:
1. Rotate / delete `admin` CLI keys (use only via console for emergencies).
2. Trigger a workflow_dispatch deploy via GH Actions to confirm OIDC path works without the bootstrap user.
3. ~~Replace the broad managed policies on `github-deploy-miti99bot` with stack-scoped custom policies.~~ **Done 2026-05-18** — see step 4 (`aws/iam-github-deploy-policy.json`) and [plan](../plans/260518-1019-iam-least-privilege/).
---
## Lambda Web Adapter layer ARN
Pinned in `template.yaml` parameter `LambdaAdapterLayerArn`. Bump by checking:
- https://github.com/awslabs/aws-lambda-web-adapter/releases (look at the `Releases` page for the latest layer version)
- Format: `arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:<version>`
## Cost expectations
After the stack is up but idle, monthly cost should be **$0**. If you ever see >$0.01 in Cost Explorer, investigate — most likely culprits: CloudWatch Logs ingestion volume, DynamoDB writes from a runaway loop, or accidental egress past the 100 GB free tier.
-236
View File
@@ -1,236 +0,0 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Budgets",
"Effect": "Allow",
"Action": [
"budgets:CreateBudget",
"budgets:CreateNotification",
"budgets:CreateSubscriber",
"budgets:DeleteBudget",
"budgets:DeleteNotification",
"budgets:DeleteSubscriber",
"budgets:DescribeBudget",
"budgets:DescribeNotificationsForBudget",
"budgets:ModifyBudget"
],
"Resource": "arn:aws:budgets::225603493174:budget/miti99bot*"
},
{
"Sid": "CloudFormation",
"Effect": "Allow",
"Action": [
"cloudformation:CancelUpdateStack",
"cloudformation:ContinueUpdateRollback",
"cloudformation:CreateChangeSet",
"cloudformation:CreateStack",
"cloudformation:DeleteChangeSet",
"cloudformation:DeleteStack",
"cloudformation:DescribeChangeSet",
"cloudformation:DescribeStackEvents",
"cloudformation:DescribeStackResources",
"cloudformation:DescribeStacks",
"cloudformation:ExecuteChangeSet",
"cloudformation:GetTemplate",
"cloudformation:GetTemplateSummary",
"cloudformation:ListChangeSets",
"cloudformation:ListStackResources",
"cloudformation:RollbackStack",
"cloudformation:TagResource",
"cloudformation:UntagResource",
"cloudformation:UpdateStack"
],
"Resource": [
"arn:aws:cloudformation:ap-southeast-1:225603493174:stack/miti99bot*/*",
"arn:aws:cloudformation:ap-southeast-1:225603493174:changeSet/*/miti99bot*/*"
]
},
{
"Sid": "CloudFormationGlobalRead",
"Effect": "Allow",
"Action": [
"cloudformation:ListStacks",
"cloudformation:ValidateTemplate"
],
"Resource": "*"
},
{
"Sid": "DynamoDB",
"Effect": "Allow",
"Action": [
"dynamodb:CreateTable",
"dynamodb:DeleteTable",
"dynamodb:DescribeContinuousBackups",
"dynamodb:DescribeTable",
"dynamodb:ListTables",
"dynamodb:ListTagsOfResource",
"dynamodb:TagResource",
"dynamodb:UntagResource",
"dynamodb:UpdateTable"
],
"Resource": "arn:aws:dynamodb:ap-southeast-1:225603493174:table/miti99bot*"
},
{
"Sid": "EventBridgeScheduler",
"Effect": "Allow",
"Action": [
"scheduler:CreateSchedule",
"scheduler:DeleteSchedule",
"scheduler:GetSchedule",
"scheduler:ListSchedules",
"scheduler:ListTagsForResource",
"scheduler:TagResource",
"scheduler:UntagResource",
"scheduler:UpdateSchedule"
],
"Resource": "arn:aws:scheduler:ap-southeast-1:225603493174:schedule/*/miti99bot*"
},
{
"Sid": "IAMRolesScoped",
"Effect": "Allow",
"Action": [
"iam:CreateRole",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:GetRole",
"iam:GetRolePolicy",
"iam:ListAttachedRolePolicies",
"iam:ListInstanceProfilesForRole",
"iam:ListRolePolicies",
"iam:ListRoleTags",
"iam:ListRoles",
"iam:PutRolePolicy",
"iam:TagRole",
"iam:UntagRole"
],
"Resource": "arn:aws:iam::225603493174:role/miti99bot*"
},
{
"Sid": "IAMPassRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::225603493174:role/miti99bot*",
"Condition": {
"ForAllValues:StringEquals": {
"iam:PassedToService": [
"lambda.amazonaws.com",
"scheduler.amazonaws.com"
]
}
}
},
{
"Sid": "Lambda",
"Effect": "Allow",
"Action": [
"lambda:AddPermission",
"lambda:CreateFunction",
"lambda:CreateFunctionUrlConfig",
"lambda:DeleteFunction",
"lambda:DeleteFunctionUrlConfig",
"lambda:GetFunction",
"lambda:GetFunctionCodeSigningConfig",
"lambda:GetFunctionConfiguration",
"lambda:GetFunctionUrlConfig",
"lambda:GetPolicy",
"lambda:ListTags",
"lambda:ListVersionsByFunction",
"lambda:PublishVersion",
"lambda:RemovePermission",
"lambda:TagResource",
"lambda:UntagResource",
"lambda:UpdateFunctionCode",
"lambda:UpdateFunctionConfiguration",
"lambda:UpdateFunctionUrlConfig"
],
"Resource": "arn:aws:lambda:ap-southeast-1:225603493174:function:miti99bot*"
},
{
"Sid": "LambdaLayerRead",
"Effect": "Allow",
"Action": "lambda:GetLayerVersion",
"Resource": "arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:*"
},
{
"Sid": "Logs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:DeleteLogGroup",
"logs:DeleteMetricFilter",
"logs:DeleteRetentionPolicy",
"logs:DescribeLogGroups",
"logs:DescribeMetricFilters",
"logs:ListTagsForResource",
"logs:PutMetricFilter",
"logs:PutRetentionPolicy",
"logs:TagResource",
"logs:UntagResource"
],
"Resource": [
"arn:aws:logs:ap-southeast-1:225603493174:log-group:/aws/lambda/miti99bot*",
"arn:aws:logs:ap-southeast-1:225603493174:log-group:/aws/lambda/miti99bot*:*"
]
},
{
"Sid": "S3SamArtifacts",
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:DeleteObject",
"s3:GetBucketLocation",
"s3:GetBucketPolicy",
"s3:GetBucketVersioning",
"s3:GetEncryptionConfiguration",
"s3:GetObject",
"s3:ListBucket",
"s3:PutBucketPolicy",
"s3:PutBucketVersioning",
"s3:PutEncryptionConfiguration",
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::aws-sam-cli-managed-default-*",
"arn:aws:s3:::aws-sam-cli-managed-default-*/*"
]
},
{
"Sid": "S3GlobalList",
"Effect": "Allow",
"Action": "s3:ListAllMyBuckets",
"Resource": "*"
},
{
"Sid": "SQS",
"Effect": "Allow",
"Action": [
"sqs:CreateQueue",
"sqs:DeleteQueue",
"sqs:GetQueueAttributes",
"sqs:GetQueueUrl",
"sqs:ListQueueTags",
"sqs:ListQueues",
"sqs:SetQueueAttributes",
"sqs:TagQueue",
"sqs:UntagQueue"
],
"Resource": "arn:aws:sqs:ap-southeast-1:225603493174:miti99bot*"
},
{
"Sid": "SSMRead",
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters"
],
"Resource": "arn:aws:ssm:ap-southeast-1:225603493174:parameter/miti99bot/*/*"
},
{
"Sid": "STS",
"Effect": "Allow",
"Action": "sts:GetCallerIdentity",
"Resource": "*"
}
]
}
-22
View File
@@ -1,22 +0,0 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::225603493174:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:tiennm99/miti99bot:ref:refs/heads/main"
]
}
}
}
]
}
-51
View File
@@ -1,51 +0,0 @@
#!/bin/sh
# Re-attaches the 10 FullAccess managed policies to github-deploy-miti99bot.
# Idempotent: attach-role-policy succeeds even if policy already attached.
# Use as emergency rollback during Phase 4 cutover (plans/260518-1019-iam-least-privilege).
#
# Usage:
# bash aws/iam-rollback-fullaccess.sh
# AWS_PROFILE=admin bash aws/iam-rollback-fullaccess.sh
ROLE=github-deploy-miti99bot
POLICIES="
arn:aws:iam::aws:policy/AWSCloudFormationFullAccess
arn:aws:iam::aws:policy/AWSLambda_FullAccess
arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
arn:aws:iam::aws:policy/AmazonEventBridgeFullAccess
arn:aws:iam::aws:policy/AmazonSQSFullAccess
arn:aws:iam::aws:policy/AmazonSSMFullAccess
arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess
arn:aws:iam::aws:policy/IAMFullAccess
arn:aws:iam::aws:policy/AmazonS3FullAccess
"
PROFILE_FLAG=""
if [ -n "$AWS_PROFILE" ]; then
PROFILE_FLAG="--profile $AWS_PROFILE"
fi
for arn in $POLICIES; do
for try in 1 2 3 4 5; do
if aws iam attach-role-policy --role-name "$ROLE" --policy-arn "$arn" $PROFILE_FLAG 2>&1; then
break
fi
echo "retry $try for $arn after throttle..."
sleep $((try * 2))
done
done
# Verify final state -- exit non-zero if anything is missing.
ATTACHED=$(aws iam list-attached-role-policies --role-name "$ROLE" $PROFILE_FLAG \
--query 'AttachedPolicies[].PolicyArn' --output text)
MISSING=0
for arn in $POLICIES; do
echo "$ATTACHED" | grep -q "$arn" || { echo "MISSING: $arn"; MISSING=1; }
done
if [ "$MISSING" = 0 ]; then
echo "Rollback complete -- all 10 FullAccess policies attached."
else
echo "Rollback INCOMPLETE -- see MISSING lines above. Re-run or attach via console."
exit 1
fi
-83
View File
@@ -1,83 +0,0 @@
# migrate-dynamo-to-mongo
One-off CLI that copies every item from the prod DynamoDB KV table
(`miti99bot-data`) into MongoDB Atlas using the exact document schema the live
app writes, then verifies per-module parity. Idempotent and re-runnable.
## What it does
- Full-table `Scan` of DynamoDB (the table is a small KV) → group by `pk`.
- Writes each item through the typed Mongo store (`storage.Typed[bson.M]`) as a
**flattened native document** — the value's JSON fields are hoisted to the
document root alongside `_id`/`version`/`updatedAt`, with no `value` envelope.
This is the exact shape the running bot writes, so the app reads migrated docs
directly. Integers keep int64 fidelity (decoded with `UseNumber`).
- The two non-object values are wrapped into named root fields to match the
module's typed shape: lolschedule `subscribers` (a JSON array) → `{subscribers: [...]}`
and `daily_push:last_date` (a bare date string) → `{date: "..."}`. Any other
non-object value fails loud so a missing wrap rule is obvious (see `encode.go`).
- Writing through the store validates the module/collection name and key and
upserts by `_id`, so a re-run produces no duplicates and bad input fails loud.
| DynamoDB | MongoDB |
|---|---|
| `pk` (module name) | collection name |
| `sk` (user key) | document `_id` |
| `value` (JSON object) | payload fields hoisted to the document root (no `value` field) |
| `value` (array/scalar, lolschedule) | wrapped in a named root field (`subscribers` / `date`) |
| — | `version` = 1, `updatedAt` = migration time (write-only; nothing reads it) |
## Usage
```sh
export MONGO_URL='mongodb+srv://botuser:PASS@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority'
export MONGO_DATABASE=miti99bot
export AWS_PROFILE=miti99bot-migrate # READ-ONLY profile (see IAM below)
# 1. Dry run — report per-module counts, write nothing.
go run ./cmd/migrate-dynamo-to-mongo --dry-run
# or: make migrate-dynamo-to-mongo DRY_RUN=1
# 2. Real migration.
go run ./cmd/migrate-dynamo-to-mongo
# or: make migrate-dynamo-to-mongo
# 3. Verify — per-module counts must match; exits non-zero on mismatch.
go run ./cmd/migrate-dynamo-to-mongo --verify
# or: make migrate-verify
```
Flags: `--dynamodb-table` (default `miti99bot-data`), `--dry-run`, `--verify`.
For a local end-to-end test, point at DynamoDB Local + a local Mongo:
```sh
DYNAMODB_LOCAL_URL=http://localhost:8001 \
MONGODB_TEST_URL=mongodb://127.0.0.1:27017 \
MONGO_DATABASE=migrate_test \
go test ./cmd/migrate-dynamo-to-mongo/ -run TestMigrateAndVerify
```
## IAM — least privilege
The runner needs **exactly** `dynamodb:Scan` on the table ARN and nothing else.
Verify uses a Scan tally (not Query), so no `dynamodb:Query` is needed; there
are **no write actions on the source**, enforcing the read-only requirement and
removing the destructive-credential foot-gun.
```json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "dynamodb:Scan",
"Resource": "arn:aws:dynamodb:ap-southeast-1:225603493174:table/miti99bot-data"
}]
}
```
## Cutover runbook
The full zero-loss cutover (disable EventBridge → `deleteWebhook` → migrate →
verify → start the polling container) lives in
[`docs/deploy-coolify-selfhosted.md`](../../docs/deploy-coolify-selfhosted.md#cutover-runbook).
-109
View File
@@ -1,109 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"go.mongodb.org/mongo-driver/v2/bson"
)
// reservedRootFields are owned by the storage layer (see internal/storage); a
// flattened object payload must not collide with them.
var reservedRootFields = map[string]bool{"_id": true, "version": true, "updatedAt": true}
// wrapRule names the root field a non-object DynamoDB value must be wrapped in
// to match the typed Mongo store's named-struct shape. rawString treats the
// DynamoDB value as a bare (non-JSON) string; otherwise it is JSON-decoded.
type wrapRule struct {
field string
rawString bool
}
// wrapRules covers the only KV entries whose value is not a JSON object. They
// mirror the named-struct wrappers the lolschedule module persists:
// - subscribers: a JSON array → {subscribers: [...]}
// - daily_push:last_date: a bare date string → {date: "..."}
//
// Any other non-object value fails loud in payloadForItem so a missing rule is
// obvious rather than silently dropped.
var wrapRules = map[[2]string]wrapRule{
{"lolschedule", "subscribers"}: {field: "subscribers"},
{"lolschedule", "daily_push:last_date"}: {field: "date", rawString: true},
}
// payloadForItem converts one migrated KV row's value into the flattened payload
// map the typed Mongo store stores at the document root. The store adds _id,
// version, and updatedAt; this returns only the payload fields.
func payloadForItem(module, key string, value []byte) (bson.M, error) {
if rule, ok := wrapRules[[2]string{module, key}]; ok {
if rule.rawString {
return bson.M{rule.field: string(value)}, nil
}
decoded, err := decodeJSONNumber(value)
if err != nil {
return nil, fmt.Errorf("%s/%s: decode value: %w", module, key, err)
}
return bson.M{rule.field: decoded}, nil
}
decoded, err := decodeJSONNumber(value)
if err != nil {
return nil, fmt.Errorf("%s/%s: decode value: %w", module, key, err)
}
obj, ok := decoded.(bson.M)
if !ok {
return nil, fmt.Errorf("%s/%s: value is not a JSON object (type %T) and has no wrap rule — add one to wrapRules", module, key, decoded)
}
for k := range obj {
if reservedRootFields[k] {
return nil, fmt.Errorf("%s/%s: payload key %q collides with a reserved root field — add a wrap rule", module, key, k)
}
}
return obj, nil
}
// decodeJSONNumber decodes JSON into a BSON-native value, preserving integral
// numbers as int64 (UseNumber) so migrated numbers keep int64 fidelity, matching
// the live store's value codec.
func decodeJSONNumber(value []byte) (any, error) {
dec := json.NewDecoder(bytes.NewReader(value))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, err
}
if dec.More() {
return nil, fmt.Errorf("trailing data after JSON value")
}
return numberToBSON(v), nil
}
// numberToBSON walks a json.Unmarshal(UseNumber) tree into bson.M/bson.A,
// converting json.Number to int64 when integral, else float64.
func numberToBSON(v any) any {
switch t := v.(type) {
case map[string]any:
m := make(bson.M, len(t))
for k, val := range t {
m[k] = numberToBSON(val)
}
return m
case []any:
a := make(bson.A, len(t))
for i, val := range t {
a[i] = numberToBSON(val)
}
return a
case json.Number:
if i, err := t.Int64(); err == nil {
return i
}
if f, err := t.Float64(); err == nil {
return f
}
return t.String()
default:
return v
}
}
@@ -1,87 +0,0 @@
package main
import (
"testing"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestPayloadForItem_Object(t *testing.T) {
got, err := payloadForItem("coin", "user:1", []byte(`{"bal":100,"meta":{"createdAt":5}}`))
if err != nil {
t.Fatalf("payloadForItem: %v", err)
}
if got["bal"] != int64(100) {
t.Errorf("bal = %v (%T), want int64(100)", got["bal"], got["bal"])
}
if _, ok := got["value"]; ok {
t.Error("payload must not contain a 'value' envelope field")
}
meta, ok := got["meta"].(bson.M)
if !ok || meta["createdAt"] != int64(5) {
t.Errorf("nested meta = %v, want {createdAt: int64(5)}", got["meta"])
}
}
func TestPayloadForItem_LolscheduleSubscribers(t *testing.T) {
got, err := payloadForItem("lolschedule", "subscribers", []byte(`[{"chatId":1},{"chatId":2}]`))
if err != nil {
t.Fatalf("payloadForItem: %v", err)
}
arr, ok := got["subscribers"].(bson.A)
if !ok || len(arr) != 2 {
t.Fatalf("subscribers = %v (%T), want 2-element bson.A", got["subscribers"], got["subscribers"])
}
}
func TestPayloadForItem_LolscheduleLastPushRawString(t *testing.T) {
// last-push date is stored as a bare (non-JSON) string in DynamoDB.
got, err := payloadForItem("lolschedule", "daily_push:last_date", []byte(`2026-06-28`))
if err != nil {
t.Fatalf("payloadForItem: %v", err)
}
if got["date"] != "2026-06-28" {
t.Errorf("date = %v, want 2026-06-28", got["date"])
}
}
func TestPayloadForItem_UnknownNonObjectFailsLoud(t *testing.T) {
if _, err := payloadForItem("coin", "user:1", []byte(`"a-bare-string"`)); err == nil {
t.Error("non-object value with no wrap rule must fail loud")
}
if _, err := payloadForItem("coin", "user:1", []byte(`[1,2,3]`)); err == nil {
t.Error("array value with no wrap rule must fail loud")
}
}
func TestPayloadForItem_ReservedKeyCollision(t *testing.T) {
if _, err := payloadForItem("coin", "user:1", []byte(`{"version":7}`)); err == nil {
t.Error("payload key colliding with reserved root field must fail loud")
}
}
// TestPayloadForItem_CamelCaseFidelity guards the invariant that lets migration
// work: the migrator preserves the original (camelCase) JSON keys, so a typed
// store struct must declare bson tags matching those names. A struct whose bson
// tags drifted to the driver's lowercased default would read these back empty.
func TestPayloadForItem_CamelCaseFidelity(t *testing.T) {
payload, err := payloadForItem("lolschedule", "events", []byte(`{"startTime":"t","gameWins":3,"blockName":"Week 1"}`))
if err != nil {
t.Fatalf("payloadForItem: %v", err)
}
raw, err := bson.Marshal(payload)
if err != nil {
t.Fatalf("bson.Marshal: %v", err)
}
var got struct {
StartTime string `bson:"startTime"`
GameWins int `bson:"gameWins"`
BlockName string `bson:"blockName"`
}
if err := bson.Unmarshal(raw, &got); err != nil {
t.Fatalf("bson.Unmarshal: %v", err)
}
if got.StartTime != "t" || got.GameWins != 3 || got.BlockName != "Week 1" {
t.Fatalf("camelCase round-trip lost data: %+v", got)
}
}
-225
View File
@@ -1,225 +0,0 @@
// Command migrate-dynamo-to-mongo copies every item from the prod DynamoDB KV
// table into MongoDB Atlas using the same document schema the live app writes,
// then verifies per-module parity. It is idempotent (re-runs overwrite by key,
// never duplicate) and read-only on DynamoDB (Scan only).
//
// Usage:
//
// migrate-dynamo-to-mongo [--dynamodb-table miti99bot-data] [--dry-run] [--verify]
//
// Required env: MONGO_URL, MONGO_DATABASE. DynamoDB credentials come from the
// AWS default chain — use a DEDICATED READ-ONLY profile whose policy grants
// exactly `dynamodb:Scan` on the table ARN (nothing else, no write actions).
// For local testing, set DYNAMODB_LOCAL_URL to point at DynamoDB Local.
//
// - default: scan + write every item through the typed Mongo store as a
// flattened native document (payload fields hoisted to the root, no `value`
// envelope) — the exact shape the live app writes — then report per-module
// counts.
// - --dry-run: scan + report counts, write nothing.
// - --verify: tally DynamoDB per pk via Scan vs Mongo CountDocuments per
// collection; print a table and exit non-zero on any mismatch.
//
// Note: each migrated doc gets a fresh updatedAt and version=1; nothing in the
// app reads updatedAt, and --verify compares counts, so this is intentional and
// harmless. The two non-object values (lolschedule subscribers array and
// last-push date) are wrapped into named root fields (see encode.go).
package main
import (
"context"
"flag"
"fmt"
"os"
"sort"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"github.com/tiennm99/miti99bot/internal/storage"
)
const defaultTable = "miti99bot-data"
func main() {
table := flag.String("dynamodb-table", defaultTable, "source DynamoDB table name")
dryRun := flag.Bool("dry-run", false, "scan and report counts without writing")
verify := flag.Bool("verify", false, "compare per-module counts DynamoDB vs Mongo and exit non-zero on mismatch")
flag.Parse()
if err := run(*table, *dryRun, *verify); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run(table string, dryRun, verify bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
mongoURL := os.Getenv("MONGO_URL")
mongoDB := os.Getenv("MONGO_DATABASE")
if mongoURL == "" || mongoDB == "" {
return fmt.Errorf("MONGO_URL and MONGO_DATABASE are required")
}
ddb, err := storage.NewDynamoDBClient(ctx, storage.DynamoDBEndpointFromEnv())
if err != nil {
return fmt.Errorf("dynamodb client: %w", err)
}
mclient, err := storage.NewMongoClient(ctx, mongoURL)
if err != nil {
return fmt.Errorf("mongo client: %w", err)
}
defer func() { _ = mclient.Disconnect(context.Background()) }()
mdb, err := storage.NewMongoDatabase(mclient, mongoDB)
if err != nil {
return err
}
if verify {
return runVerify(ctx, ddb, mdb, table)
}
return runMigrate(ctx, ddb, mdb, table, dryRun)
}
// item is one decoded DynamoDB KV row.
type item struct {
pk string // module name → collection
sk string // user key → _id
value []byte // raw value bytes
}
// scanTable reads every row from the table via a full Scan (the KV table is
// small) and decodes pk/sk/value. Scan is the ONLY DynamoDB action used, so the
// runner needs just `dynamodb:Scan` on the table ARN.
func scanTable(ctx context.Context, ddb *dynamodb.Client, table string) ([]item, error) {
var items []item
pager := dynamodb.NewScanPaginator(ddb, &dynamodb.ScanInput{TableName: aws.String(table)})
for pager.HasMorePages() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, fmt.Errorf("scan %s: %w", table, err)
}
for _, raw := range page.Items {
it, err := decodeItem(raw)
if err != nil {
return nil, err
}
items = append(items, it)
}
}
return items, nil
}
func decodeItem(raw map[string]types.AttributeValue) (item, error) {
pk, ok := raw["pk"].(*types.AttributeValueMemberS)
if !ok {
return item{}, fmt.Errorf("item missing string pk: %v", raw)
}
sk, ok := raw["sk"].(*types.AttributeValueMemberS)
if !ok {
return item{}, fmt.Errorf("item %s missing string sk", pk.Value)
}
val, ok := raw["value"].(*types.AttributeValueMemberS)
if !ok {
return item{}, fmt.Errorf("item %s/%s missing string value", pk.Value, sk.Value)
}
return item{pk: pk.Value, sk: sk.Value, value: []byte(val.Value)}, nil
}
// sortedKeys returns the map keys sorted for stable report output.
func sortedKeys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// runMigrate scans the table, then (unless dry-run) writes every item through
// the typed Mongo store as a flattened native document (payload fields hoisted
// to the root, no `value` envelope) — the exact shape the live app writes.
// payloadForItem wraps the two non-object values (lolschedule subscribers array
// and last-push date) into named-struct fields. Writing through
// storage.Typed[bson.M] reuses the store's module/key validation and version
// semantics, so a re-run produces no duplicates (upsert by _id) and an invalid
// module name or key fails loud rather than writing data the app cannot load.
func runMigrate(ctx context.Context, ddb *dynamodb.Client, mdb *mongo.Database, table string, dryRun bool) error {
items, err := scanTable(ctx, ddb, table)
if err != nil {
return err
}
counts := map[string]int{}
provider := storage.NewMongoProvider(mdb)
for _, it := range items {
counts[it.pk]++
if dryRun {
continue
}
payload, err := payloadForItem(it.pk, it.sk, it.value)
if err != nil {
return err
}
if err := storage.Typed[bson.M](provider.Collection(it.pk)).Put(ctx, it.sk, payload); err != nil {
return fmt.Errorf("put %s/%s: %w", it.pk, it.sk, err)
}
}
mode := "MIGRATED"
if dryRun {
mode = "DRY-RUN (no writes)"
}
fmt.Printf("%s — %d items across %d modules\n", mode, len(items), len(counts))
for _, pk := range sortedKeys(counts) {
fmt.Printf(" %-20s %d\n", pk, counts[pk])
}
return nil
}
// runVerify tallies DynamoDB per pk via a Scan (so only dynamodb:Scan is
// needed — never Query) and compares against Mongo CountDocuments per
// collection. Prints a table and returns an error on any mismatch so the
// process exits non-zero.
func runVerify(ctx context.Context, ddb *dynamodb.Client, mdb *mongo.Database, table string) error {
items, err := scanTable(ctx, ddb, table)
if err != nil {
return err
}
ddbCounts := map[string]int{}
for _, it := range items {
ddbCounts[it.pk]++
}
mongoCounts := map[string]int{}
for pk := range ddbCounts {
n, err := mdb.Collection(pk).CountDocuments(ctx, bson.M{})
if err != nil {
return fmt.Errorf("count mongo collection %s: %w", pk, err)
}
mongoCounts[pk] = int(n)
}
fmt.Printf("%-20s %10s %10s %s\n", "MODULE", "DYNAMODB", "MONGO", "STATUS")
mismatch := false
for _, pk := range sortedKeys(ddbCounts) {
status := "OK"
if ddbCounts[pk] != mongoCounts[pk] {
status = "MISMATCH"
mismatch = true
}
fmt.Printf("%-20s %10d %10d %s\n", pk, ddbCounts[pk], mongoCounts[pk], status)
}
if mismatch {
return fmt.Errorf("verification failed: per-module counts differ")
}
fmt.Println("verification OK: all per-module counts match")
return nil
}
-158
View File
@@ -1,158 +0,0 @@
package main
import (
"context"
"os"
"reflect"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"github.com/tiennm99/miti99bot/internal/storage"
)
func TestDecodeItem(t *testing.T) {
raw := map[string]types.AttributeValue{
"pk": &types.AttributeValueMemberS{Value: "coin"},
"sk": &types.AttributeValueMemberS{Value: "user:1"},
"value": &types.AttributeValueMemberS{Value: `{"x":1}`},
}
it, err := decodeItem(raw)
if err != nil {
t.Fatalf("decodeItem: %v", err)
}
if it.pk != "coin" || it.sk != "user:1" || string(it.value) != `{"x":1}` {
t.Errorf("decoded %+v", it)
}
// Missing value attribute is a hard error.
if _, err := decodeItem(map[string]types.AttributeValue{
"pk": &types.AttributeValueMemberS{Value: "coin"},
"sk": &types.AttributeValueMemberS{Value: "user:1"},
}); err == nil {
t.Error("decodeItem with missing value: want error, got nil")
}
}
func TestSortedKeys(t *testing.T) {
got := sortedKeys(map[string]int{"b": 1, "a": 2, "c": 3})
if !reflect.DeepEqual(got, []string{"a", "b", "c"}) {
t.Errorf("sortedKeys = %v", got)
}
}
// TestMigrateAndVerify is the end-to-end gate: seed DynamoDB Local across two
// modules, migrate into Mongo, assert values round-trip byte-identically, and
// confirm --verify reports matching counts. Skips unless BOTH emulators are
// configured.
func TestMigrateAndVerify(t *testing.T) {
ddbURL := os.Getenv("DYNAMODB_LOCAL_URL")
mongoURL := os.Getenv("MONGODB_TEST_URL")
mongoDB := os.Getenv("MONGO_DATABASE")
if ddbURL == "" || mongoURL == "" || mongoDB == "" {
t.Skip("set DYNAMODB_LOCAL_URL, MONGODB_TEST_URL, MONGO_DATABASE to run the migrator e2e test")
}
t.Setenv("AWS_ACCESS_KEY_ID", "test")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test")
t.Setenv("AWS_REGION", "ap-southeast-1")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ddb, err := storage.NewDynamoDBClient(ctx, ddbURL)
if err != nil {
t.Fatalf("dynamodb client: %v", err)
}
table := "migrate-test"
createTable(t, ctx, ddb, table)
defer func() {
_, _ = ddb.DeleteTable(ctx, &dynamodb.DeleteTableInput{TableName: aws.String(table)})
}()
seed := []item{
{pk: "coin", sk: "user:1", value: []byte(`{"bal":100}`)},
{pk: "coin", sk: "user:2", value: []byte(`{"bal":200}`)},
{pk: "stock", sk: "user:1", value: []byte(`{"vnd":5000}`)},
}
for _, it := range seed {
putDynamoItem(t, ctx, ddb, table, it)
}
// Migrate.
if err := runMigrate(ctx, ddb, mongoDatabase(t, ctx, mongoURL, mongoDB), table, false); err != nil {
t.Fatalf("runMigrate: %v", err)
}
// Re-run must stay idempotent.
if err := runMigrate(ctx, ddb, mongoDatabase(t, ctx, mongoURL, mongoDB), table, false); err != nil {
t.Fatalf("runMigrate re-run: %v", err)
}
// Verify passes.
if err := runVerify(ctx, ddb, mongoDatabase(t, ctx, mongoURL, mongoDB), table); err != nil {
t.Fatalf("runVerify: %v", err)
}
// Spot-check the migrated value round-trips through the typed store as a
// flattened native doc: bal hoisted to the root, preserved as int64.
db := mongoDatabase(t, ctx, mongoURL, mongoDB)
provider := storage.NewMongoProvider(db)
got, _, err := storage.Typed[bson.M](provider.Collection("coin")).Get(ctx, "user:1")
if err != nil {
t.Fatalf("Get migrated value: %v", err)
}
if got["bal"] != int64(100) {
t.Errorf("migrated value bal = %v (%T), want int64(100)", got["bal"], got["bal"])
}
}
func mongoDatabase(t *testing.T, ctx context.Context, uri, db string) *mongo.Database {
t.Helper()
client, err := storage.NewMongoClient(ctx, uri)
if err != nil {
t.Fatalf("NewMongoClient: %v", err)
}
t.Cleanup(func() { _ = client.Disconnect(context.Background()) })
mdb, err := storage.NewMongoDatabase(client, db)
if err != nil {
t.Fatalf("NewMongoDatabase: %v", err)
}
return mdb
}
func createTable(t *testing.T, ctx context.Context, c *dynamodb.Client, table string) {
t.Helper()
_, err := c.CreateTable(ctx, &dynamodb.CreateTableInput{
TableName: aws.String(table),
BillingMode: types.BillingModePayPerRequest,
AttributeDefinitions: []types.AttributeDefinition{
{AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS},
{AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS},
},
KeySchema: []types.KeySchemaElement{
{AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash},
{AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange},
},
})
if err != nil {
t.Fatalf("CreateTable: %v", err)
}
}
func putDynamoItem(t *testing.T, ctx context.Context, c *dynamodb.Client, table string, it item) {
t.Helper()
_, err := c.PutItem(ctx, &dynamodb.PutItemInput{
TableName: aws.String(table),
Item: map[string]types.AttributeValue{
"pk": &types.AttributeValueMemberS{Value: it.pk},
"sk": &types.AttributeValueMemberS{Value: it.sk},
"value": &types.AttributeValueMemberS{Value: string(it.value)},
},
})
if err != nil {
t.Fatalf("PutItem: %v", err)
}
}
+41 -118
View File
@@ -12,9 +12,6 @@ import (
"syscall"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ssm"
"github.com/tiennm99/miti99bot/internal/ai"
"github.com/tiennm99/miti99bot/internal/cron"
"github.com/tiennm99/miti99bot/internal/deploynotify"
@@ -79,18 +76,11 @@ func factories() map[string]modules.Factory {
// container; 10s leaves headroom without hiding a wedged cluster.
const mongodbInitTimeout = 10 * time.Second
// ssmInitTimeout caps cold-start secret resolution. Secrets are fetched once
// at startup from Parameter Store when *_PARAMETER_NAME env vars are set.
const ssmInitTimeout = 5 * time.Second
func main() {
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cfg := loadConfig()
if err := resolveSSMSecrets(rootCtx, &cfg); err != nil {
log.Fatal("ssm secret resolution failed", "err", err)
}
if cfg.TelegramBotToken == "" {
log.Fatal("missing required env", "key", "TELEGRAM_BOT_TOKEN")
}
@@ -144,10 +134,8 @@ func main() {
"commands", len(reg.AllCommands),
"crons", len(reg.Crons()))
// In-process cron scheduler. Replaces EventBridge Scheduler off AWS; runs
// unconditionally so the long-lived container fires module crons (e.g. the
// lolschedule daily push) on their Schedule. Cutover safety comes from
// ordering + the per-date idempotency guard, not from a gate.
// In-process cron scheduler runs unconditionally so the long-lived container
// fires module crons (e.g. the lolschedule daily push) on their Schedule.
stopCron, err := cron.Run(rootCtx, reg)
if err != nil {
log.Fatal("cron scheduler init failed", "err", err)
@@ -158,10 +146,10 @@ func main() {
log.Warn("OWNER_ID unset; all Private + Protected commands will be denied")
}
// Clear any webhook left over from the AWS deployment at startup, before the
// owner DM and before polling. getUpdates (long polling, below) returns HTTP
// 409 while a webhook is set, so a stuck webhook silently breaks the bot.
// Best-effort, one shot: a real failure here is logged, not retried.
// Clear any existing webhook at startup before the owner DM and before
// polling. getUpdates returns HTTP 409 while a webhook is set, so a stuck
// webhook silently breaks the bot. Best-effort, one shot: a real failure
// here is logged, not retried.
if err := telegram.DeleteWebhook(rootCtx, cfg.TelegramBotToken); err != nil {
log.Warn("deleteWebhook failed; getUpdates may 409 if a webhook is set", "err", err)
} else {
@@ -218,8 +206,7 @@ func main() {
//
// The self-host default is mongodb (just set MONGO_URL + MONGO_DATABASE — no
// KV_PROVIDER needed). The memory backend is for tests and local no-database
// runs (MODULES=). DynamoDB is no longer a runtime backend — it survives only
// as the one-off migration source (cmd/migrate-dynamo-to-mongo).
// runs (MODULES=).
//
// Returned closer is always non-nil and safe to call exactly once.
func buildProvider(ctx context.Context, cfg config) (storage.Provider, func(), error) {
@@ -265,33 +252,28 @@ func buildProvider(ctx context.Context, cfg config) (storage.Provider, func(), e
return storage.NewMongoProvider(db), closer, nil
default:
// DynamoDB is no longer a runtime backend — it survives only as the
// one-off migration source (cmd/migrate-dynamo-to-mongo).
return nil, func() {}, fmt.Errorf("unknown KV_PROVIDER %q (want memory|mongodb)", backend)
}
}
type config struct {
Port string
TelegramBotToken string
SourceCommit string // Coolify-injected commit SHA (runtime env) for deploynotify
GeminiAPIKey string
GoldPriceAPIURL string
GoldFXAPIURL string
GoldVNAppAPIURL string
GoldVNAppAPIKey string
CoinBinanceAPIURL string
CoinCoinbaseAPIURL string
CoinCoinGeckoAPIURL string
Modules []string
BotOwnerID int64
AdminUserIDs map[int64]bool
KVProvider string // empty = auto-detect; or "memory"|"mongodb"
MongoURL string // required when KVProvider=mongodb (Atlas SRV connection string; SECRET — never log)
MongoDatabase string // required when KVProvider=mongodb
TelegramBotTokenParam string
GeminiAPIKeyParam string
GoldVNAppAPIKeyParam string
Port string
TelegramBotToken string
SourceCommit string // Coolify-injected commit SHA (runtime env) for deploynotify
GeminiAPIKey string
GoldPriceAPIURL string
GoldFXAPIURL string
GoldVNAppAPIURL string
GoldVNAppAPIKey string
CoinBinanceAPIURL string
CoinCoinbaseAPIURL string
CoinCoinGeckoAPIURL string
Modules []string
BotOwnerID int64
AdminUserIDs map[int64]bool
KVProvider string // empty = auto-detect; or "memory"|"mongodb"
MongoURL string // required when KVProvider=mongodb (Atlas SRV connection string; SECRET — never log)
MongoDatabase string // required when KVProvider=mongodb
}
func loadConfig() config {
@@ -312,85 +294,26 @@ func loadConfig() config {
log.Fatal("invalid PORT", "value", port)
}
return config{
Port: port,
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
SourceCommit: envMap["SOURCE_COMMIT"],
GeminiAPIKey: envMap["GEMINI_API_KEY"],
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
GoldVNAppAPIURL: envMap["GOLD_VNAPP_API_URL"],
GoldVNAppAPIKey: envMap["GOLD_VNAPP_API_KEY"],
CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"],
CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"],
CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"],
Modules: splitCSV(envMap["MODULES"]),
BotOwnerID: parseInt64(envMap["OWNER_ID"]),
AdminUserIDs: parseInt64Set(envMap["ADMIN_IDS"]),
KVProvider: envMap["KV_PROVIDER"],
MongoURL: envMap["MONGO_URL"],
MongoDatabase: envMap["MONGO_DATABASE"],
TelegramBotTokenParam: strings.TrimSpace(envMap["TELEGRAM_BOT_TOKEN_PARAMETER_NAME"]),
GeminiAPIKeyParam: strings.TrimSpace(envMap["GEMINI_API_KEY_PARAMETER_NAME"]),
GoldVNAppAPIKeyParam: strings.TrimSpace(envMap["GOLD_VNAPP_API_KEY_PARAMETER_NAME"]),
Port: port,
TelegramBotToken: envMap["TELEGRAM_BOT_TOKEN"],
SourceCommit: envMap["SOURCE_COMMIT"],
GeminiAPIKey: envMap["GEMINI_API_KEY"],
GoldPriceAPIURL: envMap["GOLD_PRICE_API_URL"],
GoldFXAPIURL: envMap["GOLD_FX_API_URL"],
GoldVNAppAPIURL: envMap["GOLD_VNAPP_API_URL"],
GoldVNAppAPIKey: envMap["GOLD_VNAPP_API_KEY"],
CoinBinanceAPIURL: envMap["COIN_BINANCE_API_URL"],
CoinCoinbaseAPIURL: envMap["COIN_COINBASE_API_URL"],
CoinCoinGeckoAPIURL: envMap["COIN_COINGECKO_API_URL"],
Modules: splitCSV(envMap["MODULES"]),
BotOwnerID: parseInt64(envMap["OWNER_ID"]),
AdminUserIDs: parseInt64Set(envMap["ADMIN_IDS"]),
KVProvider: envMap["KV_PROVIDER"],
MongoURL: envMap["MONGO_URL"],
MongoDatabase: envMap["MONGO_DATABASE"],
}
}
func resolveSSMSecrets(ctx context.Context, cfg *config) error {
bindings := []struct {
name string
target *string
}{
{name: cfg.TelegramBotTokenParam, target: &cfg.TelegramBotToken},
{name: cfg.GeminiAPIKeyParam, target: &cfg.GeminiAPIKey},
{name: cfg.GoldVNAppAPIKeyParam, target: &cfg.GoldVNAppAPIKey},
}
targetsByName := map[string][]*string{}
names := make([]string, 0, len(bindings))
for _, b := range bindings {
if b.name == "" || *b.target != "" {
continue
}
if _, ok := targetsByName[b.name]; !ok {
names = append(names, b.name)
}
targetsByName[b.name] = append(targetsByName[b.name], b.target)
}
if len(names) == 0 {
return nil
}
initCtx, cancel := context.WithTimeout(ctx, ssmInitTimeout)
defer cancel()
awsCfg, err := awsconfig.LoadDefaultConfig(initCtx, awsconfig.WithHTTPClient(&http.Client{
Timeout: ssmInitTimeout,
}))
if err != nil {
return fmt.Errorf("load AWS config: %w", err)
}
client := ssm.NewFromConfig(awsCfg)
out, err := client.GetParameters(initCtx, &ssm.GetParametersInput{
Names: names,
WithDecryption: aws.Bool(true),
})
if err != nil {
return fmt.Errorf("get parameters: %w", err)
}
if len(out.InvalidParameters) > 0 {
return fmt.Errorf("missing SSM parameters: %s", strings.Join(out.InvalidParameters, ","))
}
for _, p := range out.Parameters {
name := aws.ToString(p.Name)
value := aws.ToString(p.Value)
for _, target := range targetsByName[name] {
*target = value
}
}
log.Info("loaded secrets from ssm", "count", len(out.Parameters))
return nil
}
func exportOptionalEnv(key, value string) {
if strings.TrimSpace(value) == "" {
return
-2
View File
@@ -28,8 +28,6 @@ services:
# PORT defaults to 8080 (internal health server) — omit unless overriding.
# Long polling = no TELEGRAM_WEBHOOK_SECRET, no /webhook, no public domain.
# Cron is in-process only — there is no /cron HTTP route and no secret.
# Do NOT set any *_PARAMETER_NAME vars (those force an SSM/AWS lookup that
# fails with no AWS creds and bricks startup). See .env.example.
# No stock/coin/gold *_API_URL overrides — modules use their coded default
# providers (stock: SSI/VCI/KBS; coin: Binance→Coinbase→CoinGecko;
# gold: VNAppMob→spot). GOLD_VNAPP_API_KEY auto-fetches + caches to Mongo.
-126
View File
@@ -1,126 +0,0 @@
# AWS Decommission Runbook
Delete **everything** `miti99bot` ever deployed to AWS, after the migration +
cutover to Coolify is verified. Run by the operator with the `admin` profile.
> **Precondition (hard):** run ONLY after the Phase 4 cutover —
> `make migrate-verify` green, bot confirmed live on Coolify via long polling,
> and the EventBridge schedule already disabled at cutover. `sam delete`
> destroys the DynamoDB table, which is the sole copy of prod data until
> migrated. Never run this standalone.
Account `225603493174`, region `ap-southeast-1` (verified live 2026-06-27).
## What `sam delete` removes (CloudFormation-managed)
DynamoDB table `miti99bot-data`, the Lambda + Function URL + invoke
permissions, the Lambda execution role and `SchedulerExecutionRole`, the
`/aws/lambda/miti99bot` log group + metric filter, the `miti99bot-cron-dlq`
SQS queue, the `miti99bot-lolschedule-daily-push` schedule, and the
`miti99bot-monthly` budget (if `AlertEmail` was set).
## What it does NOT remove (created manually, outside CloudFormation)
These linger — and the SSM secrets keep your bot token / Gemini key in the
cloud — unless deleted separately:
- **SSM SecureStrings** (exactly 4): `/miti99bot/prod/telegram-bot-token`,
`/miti99bot/prod/telegram-webhook-secret`, `/miti99bot/prod/gemini-api-key`,
`/miti99bot/prod/cron-shared-secret`. **Deleting these is the security step.**
- **IAM role** `github-deploy-miti99bot` + inline policy `miti99bot-deploy`.
- **IAM OIDC provider** `token.actions.githubusercontent.com` — verified the
account's only OIDC provider and used solely by miti99bot → safe to delete.
- **SAM deploy bucket** `aws-sam-cli-managed-default-samclisourcebucket-ctwpsmoxnwvm`
+ bootstrap stack `aws-sam-cli-managed-default` — verified miti99bot is the
sole SAM project → safe to delete.
## Runbook
```sh
AWS_PROFILE=admin; REGION=ap-southeast-1; ACCT=225603493174
# 1. Safety check — stack still exists (about to be deleted).
aws --profile $AWS_PROFILE cloudformation describe-stacks --stack-name miti99bot \
--query "Stacks[0].StackStatus"
# 2. Delete the CloudFormation stack.
aws --profile $AWS_PROFILE sam delete --stack-name miti99bot --region $REGION --no-prompts
aws --profile $AWS_PROFILE cloudformation wait stack-delete-complete --stack-name miti99bot
# 3. Delete SSM secrets (NOT CFN-managed). List first, then delete.
aws --profile $AWS_PROFILE ssm get-parameters-by-path --path /miti99bot --recursive \
--query "Parameters[].Name" --output text
for P in telegram-bot-token telegram-webhook-secret gemini-api-key cron-shared-secret; do
aws --profile $AWS_PROFILE ssm delete-parameter --name /miti99bot/prod/$P
done
# delete any extra /miti99bot/* the list revealed
# 4. Delete the GitHub deploy IAM role (inline policy first).
aws --profile $AWS_PROFILE iam delete-role-policy \
--role-name github-deploy-miti99bot --policy-name miti99bot-deploy
aws --profile $AWS_PROFILE iam delete-role --role-name github-deploy-miti99bot
# 5. OIDC provider — re-confirm it's the only one, then delete.
aws --profile $AWS_PROFILE iam list-open-id-connect-providers
aws --profile $AWS_PROFILE iam delete-open-id-connect-provider \
--open-id-connect-provider-arn arn:aws:iam::$ACCT:oidc-provider/token.actions.githubusercontent.com
# 6. SAM deploy bucket + bootstrap stack — re-confirm only miti99bot +
# aws-sam-cli-managed-default stacks exist first.
aws --profile $AWS_PROFILE cloudformation list-stacks \
--query "StackSummaries[?StackStatus!='DELETE_COMPLETE'].StackName" --output text
aws --profile $AWS_PROFILE s3 rb \
s3://aws-sam-cli-managed-default-samclisourcebucket-ctwpsmoxnwvm --force
aws --profile $AWS_PROFILE cloudformation delete-stack --stack-name aws-sam-cli-managed-default
# 7. Confirm nothing tagged app=miti99bot remains.
aws --profile $AWS_PROFILE resourcegroupstaggingapi get-resources \
--tag-filters Key=app,Values=miti99bot --region $REGION
aws --profile $AWS_PROFILE cloudformation list-stacks \
--query "StackSummaries[?contains(StackName,'miti99bot')].[StackName,StackStatus]" --output table
```
Then in the repo: the `.github/workflows/deploy.yml` AWS deploy is disabled on
the `feature/selfhosted` branch (the trigger is removed so a `main` push can't
recreate the stack).
## Verification checklist
Teardown executed and verified 2026-06-28 (account `225603493174`, region
`ap-southeast-1`).
- [x] `describe-stacks --stack-name miti99bot` → does not exist.
- [x] No `/miti99bot/*` SSM parameters remain (secrets purged).
- [x] `github-deploy-miti99bot` role gone; OIDC provider gone.
- [x] SAM bucket + bootstrap stack deleted.
- [x] `resourcegroupstaggingapi` for `app=miti99bot` returns empty.
- [x] `deploy.yml` no longer recreates the stack on `main` (trigger is
`workflow_dispatch`-only and depends on the deleted OIDC role).
- [ ] Cost Explorer shows $0 the following billing period.
- [x] Cloudflare verified clean (2026-06-27): legacy KV/D1 already gone; the 4
remaining Workers are separate active projects. No action.
- [x] GCP: project never used GCP (README lists AWS + Cloudflare only); `gcloud`
not installed in the teardown environment, nothing to clean.
## Deviations during execution (2026-06-28)
Two steps differed from the assumptions above; recorded for future reference:
- **IAM role policies:** `github-deploy-miti99bot` had **10 AWS-managed
policies attached** (`AmazonSSMFullAccess`, `IAMFullAccess`, ...), not the
inline `miti99bot-deploy` policy this runbook assumed — likely the broad-access
fallback from `aws/iam-rollback-fullaccess.sh`. Had to `detach-role-policy`
for all 10 (AWS-managed, so detach only unlinks them) before `delete-role`.
- **Versioned SAM bucket:** `s3 rb --force` failed with `BucketNotEmpty` because
the bucket had versioning on — `--force` removes only current versions, leaving
126 non-current versions + 126 delete markers. Purged every version + delete
marker via `s3api list-object-versions` + `delete-objects`, then
`delete-bucket`, then retried the bootstrap `delete-stack` (it had gone
`DELETE_FAILED` solely because the bucket couldn't be emptied).
## Notes
- **Secret hygiene (optional):** rotate the Telegram bot token + Gemini key
after teardown — they lived in SSM/CloudWatch under accepted trade-offs.
- **Keep in git:** the `aws/` dir + `template.yaml` history cost nothing and are
useful if AWS is ever revisited.
-415
View File
@@ -1,415 +0,0 @@
# Deploy miti99bot to AWS (Free Tier)
> **RETIRED.** `miti99bot` is now self-hosted on Coolify + MongoDB Atlas — see
> [`deploy-coolify-selfhosted.md`](./deploy-coolify-selfhosted.md). The AWS stack
> is decommissioned ([`aws-decommission-runbook.md`](./aws-decommission-runbook.md)).
> Kept for historical reference / if AWS is ever revisited.
End-to-end onboarding guide for deploying `miti99bot` on AWS. Everything below stays inside the AWS free tier in region `ap-southeast-1` (Singapore).
Related docs:
- One-time bootstrap reference: [`aws/README.md`](../aws/README.md)
- Steady-state operations: [`deploy-aws.md`](./deploy-aws.md)
---
## Project Standards (Non-Negotiables)
### Standard 1: Free Tier Is a Hard Requirement
Any AWS line item that incurs a cost is disqualified, even if monthly usage rounds the bill to $0.00. The principle: **no per-invocation or per-request charges**, period. Free-tier qualification is evaluated per service (not aggregate).
**Disqualified resources:** EventBridge ApiDestination invocations ($0.20/M, no free tier).
**Free-tier-covered resources:** Lambda (1M req + 400k GB-s/mo), DynamoDB on-demand (2.5M read + 1M write units/mo), EventBridge Scheduler (14M invocations/mo), SQS (1M req/mo), CloudWatch Logs (5 GB ingest/mo), CloudFormation, IAM, Budgets, SSM Parameter Store Standard tier.
When evaluating new AWS resources, explicitly verify per-service free-tier coverage in the AWS pricing docs. The `MonthlyBudget` resource (see below) is a safety net, but design must not depend on it. If you hit the $1 threshold, something violated this standard.
### Standard 2: Security Trade-Offs Accepted
Non-negotiable security boundary: AWS resources must not be publicly reachable except via the designed surface — Lambda Function URL serving `/webhook` (Telegram) and `/cron/{name}` (EventBridge Scheduler). Everything else is private or IAM-gated.
Trade-offs explicitly accepted in this project (do NOT flag as "harden this" unless they breach the boundary above):
- Secrets in CloudWatch Logs / Lambda environment (visible to anyone with logs:GetLogEvents — IAM gates that).
- Secrets embedded in EventBridge Scheduler Target.Input (visible to anyone with scheduler:GetSchedule — IAM gates that).
- NoEcho CloudFormation parameters instead of Secrets Manager (simplifies deployment, avoids storage cost).
- SSM SecureString fetched at Lambda cold start instead of Secrets Manager with rotation (faster, no API call overhead, acceptable risk given Lambda is not a long-lived server).
Rationale: in this project, **free-tier compliance outweighs secret-storage maturity**. Architectural change (Secrets Manager, rotation, KMS key rotation) would either cost money or require additional infrastructure to stay free-tier. Rejected in favor of free tier: EventBridge Connection's service-linked Secrets Manager secret (would cost).
**Red flags that should trigger review:** Any change that (a) makes a non-Function-URL AWS resource publicly reachable, (b) changes Function URL's AuthType from NONE (mitigated by app-level token check), (c) adds a second public-facing Lambda endpoint, (d) ships `cronDisabled=true` due to empty CronSharedSecret.
---
## What you get (all free-tier)
| Resource | Free quota | This bot's usage |
|---|---|---|
| Lambda (ARM64, 256 MB) | 1M req + 400k GB-s / mo, **always-free** | far below |
| Lambda Function URL | included with Lambda | the Telegram webhook entrypoint |
| DynamoDB on-demand | 25 GiB storage + 2.5M read / 1M write request units / mo **always-free** | far below |
| EventBridge Scheduler | 14M invocations / mo always-free | a few crons |
| SSM Parameter Store (Standard) | unlimited free | 4 SecureString params |
| CloudWatch Logs | 5 GB ingest, 5 GB storage / mo | well below at 7-day retention |
| SQS (cron DLQ) | 1M req / mo always-free | near zero |
| CloudFormation, IAM, Budgets | free | |
| Egress | 100 GB / mo always-free | tiny |
Paid traps the template already avoids: DynamoDB PITR disabled, no NAT Gateway, no API Gateway, no provisioned concurrency, no VPC, log retention pinned to 7 days, X-Ray "Active" tracing stays within the 100k traces/mo free tier.
---
## Prerequisites (Ubuntu 24.04 ARM64)
Host arch matches Lambda's `arm64` target, so `make build-lambda` is a native build (still pinned to `GOARCH=arm64` for reproducibility).
```sh
sudo apt update
sudo apt install -y curl jq make git python3 python3-venv python3-pip
```
### AWS CLI + SAM CLI (project-local venv via pip)
Ubuntu 24.04 enforces PEP 668 (externally-managed system Python), so we install both tools inside a project-local `.venv`. From the repo root:
```sh
cd /path/to/miti99bot
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install awscli aws-sam-cli
aws --version # aws-cli/1.x (pip ships v1; v2 is not on PyPI)
sam --version
```
Activate the venv at the start of every shell session you use for AWS commands:
```sh
source /path/to/miti99bot/.venv/bin/activate
```
If you want auto-activation, install [`direnv`](https://direnv.net/) (`sudo apt install direnv`, then `eval "$(direnv hook bash)"` in `~/.bashrc`) and drop a `.envrc` in the repo containing `source .venv/bin/activate`. Don't override the shell built-in `cd` — that affects every directory you ever enter.
> **Note:** PyPI's `awscli` is **v1** (v2 is only distributed as the standalone bundle). v1 covers every command used in this guide — `ssm`, `iam`, `cloudformation`, `lambda`, `logs`, `ce`, `cloudwatch`. If you later need v2-only features (e.g. SSO login, new `aws configure sso` flows), install v2 separately from the official ARM zip and keep both. SAM CLI on PyPI tracks upstream releases — `pip install -U aws-sam-cli` to bump.
Add `.venv/` to `.gitignore` if not already there:
```sh
grep -qxF '.venv/' .gitignore || echo '.venv/' >> .gitignore
```
### Go 1.25 (ARM64)
Ubuntu 24.04 ships an older Go. Install the upstream tarball:
```sh
GO_VERSION=1.25.0
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-arm64.tar.gz" -o /tmp/go.tgz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf /tmp/go.tgz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc
go version # expect go1.25.x linux/arm64
```
### GitHub CLI (`gh`)
```sh
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
| sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null
sudo apt update
sudo apt install -y gh
```
### Docker (optional — only for DynamoDB Local tests)
```sh
sudo apt install -y docker.io
sudo usermod -aG docker "$USER"
newgrp docker
```
### Accounts / keys
- AWS account (root access).
- Telegram bot token from BotFather.
- Gemini API key from Google AI Studio (free).
- This repo cloned locally.
---
## Step 1 — AWS account hygiene
1. Log in to the AWS root user, enable MFA.
2. Create an IAM user `admin` with `AdministratorAccess` and CLI access keys. This is used only for the first deploy.
3. Configure the CLI:
```sh
aws configure set region ap-southeast-1 --profile admin
aws configure set aws_access_key_id AKIA… --profile admin
aws configure set aws_secret_access_key … --profile admin
```
---
## Step 2 — Store the 4 secrets in SSM Parameter Store
Parameter Store Standard tier is free; SecureString uses the AWS-managed KMS key, also free. `--tier Standard` is the default — never pass `--tier Advanced` (that costs $0.05/param/month).
> **Shell-history hygiene.** Long-lived tokens (BotFather, Gemini) are passed below via `read -s` so they never appear in `~/.bash_history`, `ps`, or backups of either. The two short-lived random tokens (webhook + cron) are generated inline with `openssl rand`.
Real secrets — read each value interactively, no echo:
```sh
read -rsp 'BotFather token: ' BOT_TOKEN && echo
aws ssm put-parameter --profile admin --type SecureString --overwrite \
--name /miti99bot/prod/telegram-bot-token --value "$BOT_TOKEN"
unset BOT_TOKEN
read -rsp 'Gemini API key: ' GEMINI_KEY && echo
aws ssm put-parameter --profile admin --type SecureString --overwrite \
--name /miti99bot/prod/gemini-api-key --value "$GEMINI_KEY"
unset GEMINI_KEY
```
> Skip the Gemini one if you're not using the `twentyq` module — store `"unused"` so the Lambda startup secret fetch still succeeds, then drop `twentyq` from `MODULES`.
Generated secrets — random hex, no input needed:
```sh
aws ssm put-parameter --profile admin --type SecureString --overwrite \
--name /miti99bot/prod/telegram-webhook-secret --value "$(openssl rand -hex 32)"
aws ssm put-parameter --profile admin --type SecureString --overwrite \
--name /miti99bot/prod/cron-shared-secret --value "$(openssl rand -hex 32)"
```
The webhook + cron values are fetched back in Step 5 via `aws ssm get-parameter` — no need to copy them out by hand.
Confirm all four exist (names only, no values):
```sh
aws ssm get-parameters-by-path --profile admin \
--path /miti99bot/prod/ --query 'Parameters[].Name' --output table
```
---
## Step 3 — Register GitHub OIDC + deploy role (one-time)
> Run all commands in this step from the repo root — `aws/iam-github-oidc-trust.json` is referenced as a relative path.
```sh
cd /path/to/miti99bot
```
Register the OIDC identity provider (idempotent — skips creation if it already exists):
```sh
ACCT=$(aws sts get-caller-identity --profile admin --query Account --output text)
OIDC_ARN="arn:aws:iam::${ACCT}:oidc-provider/token.actions.githubusercontent.com"
aws iam get-open-id-connect-provider --profile admin \
--open-id-connect-provider-arn "$OIDC_ARN" >/dev/null 2>&1 \
|| aws iam create-open-id-connect-provider --profile admin \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
```
Edit `aws/iam-github-oidc-trust.json` if you're deploying from a different AWS account or repo fork. This repo is already prefilled for account `225603493174` and `tiennm99/miti99bot`:
```sh
sed -i "s|225603493174|$ACCT|" aws/iam-github-oidc-trust.json # only if you are changing accounts
```
If you change accounts, update `.github/workflows/deploy.yml` to match the same role ARN.
Create the role (idempotent — `update-assume-role-policy` if it already exists):
```sh
aws iam get-role --profile admin --role-name github-deploy-miti99bot >/dev/null 2>&1 \
&& aws iam update-assume-role-policy --profile admin \
--role-name github-deploy-miti99bot \
--policy-document file://aws/iam-github-oidc-trust.json \
|| aws iam create-role --profile admin \
--role-name github-deploy-miti99bot \
--assume-role-policy-document file://aws/iam-github-oidc-trust.json
```
Attach the managed policies (re-attaching the same policy is a no-op, so this loop is safely re-runnable):
```sh
for arn in \
arn:aws:iam::aws:policy/AWSCloudFormationFullAccess \
arn:aws:iam::aws:policy/AWSLambda_FullAccess \
arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess \
arn:aws:iam::aws:policy/AmazonEventBridgeFullAccess \
arn:aws:iam::aws:policy/AmazonSQSFullAccess \
arn:aws:iam::aws:policy/AmazonSSMFullAccess \
arn:aws:iam::aws:policy/CloudWatchLogsFullAccess \
arn:aws:iam::aws:policy/AWSBudgetsActionsWithAWSResourceControlAccess \
arn:aws:iam::aws:policy/IAMFullAccess \
arn:aws:iam::aws:policy/AmazonS3FullAccess; do
aws iam attach-role-policy --profile admin \
--role-name github-deploy-miti99bot --policy-arn "$arn"
done
```
These managed policies are intentionally broad for the first deploy. `IAMFullAccess` and `AmazonS3FullAccess` are the widest blast radius — tighten them first when you reach Step 7.
---
## Step 4 — First deploy (manual)
```sh
cd /path/to/miti99bot
make build-lambda # cross-compiles Go → linux/arm64
AWS_PROFILE=admin sam deploy --template-file template.yaml --guided # accept samconfig.toml defaults
```
> **Why `--template-file template.yaml`.** By default `sam deploy` looks for `.aws-sam/build/template.yaml` (output of `sam build`). We skip `sam build` because SAM's default builder for `provided.al2023` expects a `Makefile` inside `CodeUri: build/lambda/` — which is the *output* directory of `make build-lambda`, not a source dir. Pointing `sam deploy` at the raw source template tells it to read `CodeUri: build/lambda/` directly, zip the bootstrap binary, upload it, and deploy. The `make build-lambda` step above is the actual compile.
Confirm at the SAM prompt:
- Stack name: `miti99bot`
- Region: `ap-southeast-1`
- Capabilities: `CAPABILITY_IAM`
- Save to `samconfig.toml`: yes
After `CREATE_COMPLETE`, grab the Function URL:
```sh
aws cloudformation describe-stacks --profile admin \
--stack-name miti99bot \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text
```
---
## Step 5 — Point Telegram at the webhook
> For first-time setup only. After `Step 6` wires the GitHub workflow, every push to `main` auto-runs `setWebhook` + `setMyCommands`; this manual block is the break-glass path.
```sh
URL=… # from previous command
TOKEN=$(aws ssm get-parameter --profile admin \
--name /miti99bot/prod/telegram-bot-token --with-decryption \
--query Parameter.Value --output text)
SECRET=$(aws ssm get-parameter --profile admin \
--name /miti99bot/prod/telegram-webhook-secret --with-decryption \
--query Parameter.Value --output text)
curl -X POST "https://api.telegram.org/bot$TOKEN/setWebhook" \
-d "url=${URL}webhook" \
-d "secret_token=$SECRET" \
-d "allowed_updates=[\"message\",\"callback_query\"]"
curl "https://api.telegram.org/bot$TOKEN/getWebhookInfo" | jq .
```
Expect: `url` matches Function URL, `pending_update_count` ≈ 0, `last_error_date` empty. Send `/start` to the bot — response should arrive within a couple seconds.
---
## Step 6 — Wire GitHub Actions for future deploys
In GitHub → repo Settings → Secrets and variables → Actions:
| Secret | Value |
|---|---|
| `ALERT_EMAIL` (optional) | Email for the $1 budget alert |
After this, every push to `main` triggers `.github/workflows/deploy.yml`:
1. OIDC assume `github-deploy-miti99bot` role
2. `make build-lambda`
3. `sam deploy --template-file template.yaml`
4. Smoke `curl <function-url>/`
No long-lived keys live in GitHub. The deploy workflow now uses the repo's fixed AWS account ID directly for the OIDC role ARN, so `AWS_ACCOUNT_ID` no longer needs to be stored in GitHub.
---
## Step 7 — Lock down (recommended once it works)
1. Rotate / delete `admin` CLI keys (keep the user for console-only emergencies).
2. Trigger a `workflow_dispatch` deploy via GH Actions to confirm OIDC path works without the bootstrap user.
3. Replace the broad managed policies on `github-deploy-miti99bot` with stack-scoped custom policies (resource ARNs from your stack).
---
## Step 8 — Cost guardrails
- Set `ALERT_EMAIL` → enables a $1/mo AWS Budgets alarm at 80% and 100%.
- Daily checks (logs, DDB throttle, cold-start P95, MTD spend) → see [`deploy-aws.md`](./deploy-aws.md) "Operational checks".
- Idle steady-state cost is **$0**. If you ever see >$0.01 in Cost Explorer, investigate. Most likely culprits:
- CloudWatch Logs ingestion volume (verbose logging, hot loops).
- DynamoDB writes from a runaway loop.
- Accidental egress past the 100 GB free tier.
---
## Free-tier watch table
| Resource | Free | Watch when |
|---|---|---|
| Lambda req / GB-s | 1M / 400k | Past 50% mid-month |
| DynamoDB req | 200M | Past 5% (sign of runaway loop) |
| DynamoDB storage | 25 GiB | Past 100 MiB (suspect leaks) |
| EventBridge invocations | 14M | Past 1k/mo (suspect mis-config) |
| CloudWatch Logs ingest | 5 GB | Past 50% mid-month |
| Egress | 100 GB | Past 1 GB (wildly high) |
The $1 budget alarm catches all of these via cost-side fallout.
---
## Rollback
CloudFormation has three rollback flavors. Pick the one that matches your situation.
### Case A — `sam deploy` is currently failing
CloudFormation auto-initiates a rollback. Nothing to do. If the rollback itself fails (`UPDATE_ROLLBACK_FAILED`):
```sh
aws cloudformation continue-update-rollback --profile admin \
--stack-name miti99bot
```
### Case B — `sam deploy` is running and you want to abort
```sh
aws cloudformation cancel-update-stack --profile admin \
--stack-name miti99bot
```
CloudFormation rolls back to the prior `CREATE_COMPLETE` / `UPDATE_COMPLETE` state.
### Case C — Deploy succeeded but the code is bad
CloudFormation has no "redeploy previous template" command (`--use-previous-template` re-applies the *current* template, not an older one — it does **not** roll back). Redeploy from the last known-good commit:
```sh
git checkout <good-sha>
make build-lambda
make sam-deploy
```
To find the last good SHA quickly: `git log --oneline -- template.yaml cmd/server` and pick the commit that matches a passing deploy.
---
## Rotating secrets
```sh
aws ssm put-parameter --profile admin --type SecureString --overwrite \
--name /miti99bot/prod/telegram-webhook-secret \
--value "$(openssl rand -hex 32)"
# Recycle the Lambda before switching Telegram to the new secret, or wait for
# AWS to create a fresh execution environment naturally.
```
> `template.yaml` passes only SSM parameter names to Lambda. The app fetches the current SecureString values during cold start, so no secret values are embedded in CloudFormation. Existing warm Lambda environments keep the old value until AWS recycles them or you force a function update; rotate the Telegram webhook secret by refreshing Lambda first, then re-running Step 5 with the new `secret_token`.
-216
View File
@@ -1,216 +0,0 @@
# Deploy: AWS (Lambda + DynamoDB + EventBridge)
> **RETIRED.** `miti99bot` is now self-hosted on Coolify + MongoDB Atlas — see
> [`deploy-coolify-selfhosted.md`](./deploy-coolify-selfhosted.md). The AWS stack
> is decommissioned ([`aws-decommission-runbook.md`](./aws-decommission-runbook.md)).
> Kept for historical reference / if AWS is ever revisited.
This is the production deploy path for `miti99bot`. Strict free-tier targets, region `ap-southeast-1`.
> **First-time setup:** see `aws/README.md`. This doc is for steady-state operations.
## Architecture (one diagram)
```
Telegram ──HTTPS──► Lambda Function URL (AuthType: NONE)
└─► AWS Lambda Web Adapter ──► localhost:8080
└─► Go http.Handler (cmd/server)
├─► DynamoDB (KV)
├─► Gemini API (AI modules)
└─► Telegram Bot API (replies)
EventBridge Scheduler ──cron──► HTTPS POST <FunctionURL>/cron/{name}
+ Header X-Cron-Token (from SSM)
```
## Deploy
### Via GitHub Actions (canonical)
```
git push origin main
```
Triggers `.github/workflows/deploy.yml`:
1. OIDC assume `github-deploy-miti99bot` role
2. `make build-lambda` (Go ARM64 ZIP-ready binary)
3. `sam deploy --template-file template.yaml`
4. Smoke `curl <function-url>/`
### Manual (emergency / staging)
```sh
make build-lambda
make sam-deploy # uses samconfig.toml defaults
ALERT_EMAIL=you@example.com make sam-deploy # with budget alert wired
```
## Verify
```sh
make logs SINCE=10m
aws cloudformation describe-stacks --stack-name miti99bot \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text
curl -fsSL "$(...)/" | jq . # health JSON
```
## Set the Telegram webhook
> `.github/workflows/deploy.yml` auto-runs `setWebhook` + `setMyCommands` after every push to `main`. The snippet below is the break-glass equivalent for manual / out-of-band fixes (e.g. rerun from a workstation when CI is unavailable).
```sh
URL=$(aws cloudformation describe-stacks --stack-name miti99bot \
--query "Stacks[0].Outputs[?OutputKey=='FunctionUrl'].OutputValue" --output text)
SECRET=$(aws ssm get-parameter --name /miti99bot/prod/telegram-webhook-secret \
--with-decryption --query 'Parameter.Value' --output text)
TOKEN=$(aws ssm get-parameter --name /miti99bot/prod/telegram-bot-token \
--with-decryption --query 'Parameter.Value' --output text)
curl -X POST "https://api.telegram.org/bot$TOKEN/setWebhook" \
-d "url=${URL}webhook" \
-d "secret_token=$SECRET" \
-d "drop_pending_updates=false" \
-d "allowed_updates=[\"message\",\"callback_query\"]"
```
Verify:
```sh
curl "https://api.telegram.org/bot$TOKEN/getWebhookInfo" | jq .
```
Expect: `url` matches Function URL, `pending_update_count` ≈ 0, `last_error_date` empty.
## Adding a module or command (registration checklist)
A module only runs in production if its name is in **both** `ModulesCSV` sources — the
`template.yaml` default is ignored once an override is passed, so editing one place is
not enough. A command only appears in the Telegram menu if it is in
`aws/telegram-commands.json`. Missing either is silent: no error, the command just
never dispatches (this is how `coin_*` shipped dark until `coin` was added to the CSVs).
When **adding a new module**, register it in all of:
1. `cmd/server/main.go` — add the factory to the catalog (`"name": pkg.New`).
2. `.github/workflows/deploy.yml` — append the name to `ModulesCSV=…` (CI override).
3. `samconfig.toml` — append the name to `ModulesCSV=…` (manual-deploy override; keep in sync with the workflow).
4. `template.yaml` — append to the `ModulesCSV` `Default` (documents the full set).
5. `aws/telegram-commands.json` — add each new command + description for the Telegram menu.
When **adding a command to an existing, already-enabled module**, only step 5 applies.
**On push to `main`:** CI redeploys and re-runs `setMyCommands` from
`aws/telegram-commands.json` automatically. The Telegram client caches the command
menu, so a changed menu may not show until the chat is reopened — confirm with
`make telegram-commands-info` (calls `getMyCommands`) rather than trusting the app UI.
Only when a push introduces **new public commands** (`VisibilityPublic`) does the menu
need attention — re-confirm registration for those pushes; routine pushes (refactors,
fixes, non-public commands) need no menu action.
## Stock income events API
`/stock_income_events` uses a FireAnt REST API, configured at Lambda runtime:
- `STOCK_INCOME_EVENTS_API_URL`: FireAnt base URL; defaults to `https://restv2.fireant.vn`. The bot calls `/symbols/{symbol}/timescale-marks` with `startDate` and `endDate`.
- `STOCK_INCOME_EVENTS_API_TOKEN`: bearer token for FireAnt. Store it directly only for local dev; in AWS prefer `STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME`.
FireAnt response is an array of timescale marks with `id`, `label`, `date`, `title`, and `color`. The bot keeps marks whose label/title indicate dividends, ex-right dates, final registration dates, rights issues, or bonus/share dividends.
## Stock price providers
`/stock_buy`, `/stock_sell`, and `/stock_stats` use unofficial public quote endpoints. Zero-value provider order is:
1. KBS current price board (`/stock/iss`).
2. VCI current quote board (`/price/symbols/getList`).
3. SSI iBoard direct quote.
This order is intentional for current-price commands. KBS and VCI both support batch current quotes, while SSI can return a Cloudflare security page. Treat all three as unofficial app-internal endpoints and keep provider/source errors visible in Lambda logs.
## Gold module
`gold` is opt-in for first deploy. Enable it by adding `gold` to the `ModulesCSV` parameter / `MODULES` env, for example `util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold`.
Commands:
- `/gold_price` shows current gold price. When VNAppMob SJC is available it prints buy/sell/mid VND/lượng; otherwise it falls back to world spot XAU in USD/oz plus USD/VND rate.
- `/gold_topup <amount>` credits VND. No currency argument is accepted.
- `/gold_buy <luong>` buys gold in Vietnamese `luong`. No symbol or unit argument is accepted.
- `/gold_sell <luong>` sells gold in Vietnamese `luong`.
- `/gold_stats` shows VND balance, gold holding, current price, total value, invested amount, and P&L.
Price source: primary is VNAppMob Vietnam SJC price feed (`api.vnappmob.com/api/v2/gold/sjc`), which returns VND/lượng directly. The client auto-refreshes a free JWT API key and caches it in KV. If VNAppMob fails, the bot falls back to world spot XAU from GoldPrice.org converted through USD/VND from ExchangeRate-API open endpoint. The defaults require no secrets. Optional overrides:
- `GOLD_VNAPP_API_URL`: VNAppMob API base URL override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests.
- `GOLD_VNAPP_API_KEY`: pre-issued VNAppMob JWT key. When set, auto-refresh is skipped. Useful for local dev or SSM injection.
- `GOLD_VNAPP_API_KEY_PARAMETER_NAME`: SSM SecureString parameter name containing the pre-issued key. Fetched at Lambda cold start.
- `GOLD_PRICE_API_URL`: fallback gold spot JSON endpoint override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests.
- `GOLD_FX_API_URL`: fallback USD/VND FX JSON endpoint override. Remote URLs must be HTTPS; localhost HTTP is allowed for local tests.
ExchangeRate-API open endpoint requires attribution if surfaced publicly and updates once per day; the bot caches FX responses until the provider `time_next_update_unix` when available.
## Rotate secrets
```sh
aws ssm put-parameter --name /miti99bot/prod/telegram-webhook-secret \
--value "$(openssl rand -hex 32)" --type SecureString --overwrite
# template.yaml uses ":1" version pin; redeploy to pick up the new value:
make sam-deploy
# Then re-run setWebhook (above) with the new secret_token.
```
> The `:1` in `{{resolve:ssm-secure:…:1}}` is the parameter **version** — it pins to the latest version at deploy time, not version 1 forever. To force a refresh after rotation, redeploy.
## Rollback
CloudFormation handles failed deploys: a failing `sam deploy` triggers automatic rollback to the prior version. To roll back a successful-but-bad deploy:
```sh
aws cloudformation update-stack \
--stack-name miti99bot \
--use-previous-template \
--capabilities CAPABILITY_IAM
```
Or redeploy from a known-good commit:
```sh
git checkout <good-sha>
make sam-deploy
```
## Operational checks (daily during 7-day soak)
```sh
# Errors / warnings in last 24h
aws logs filter-log-events --log-group-name /aws/lambda/miti99bot \
--start-time $(($(date +%s%3N) - 86400000)) \
--filter-pattern '{ $.level = "ERROR" }' --max-items 20
# Cold start P95
aws logs start-query --log-group-name /aws/lambda/miti99bot \
--start-time $(($(date +%s) - 86400)) --end-time $(date +%s) \
--query-string 'filter @type = "REPORT" | stats avg(@initDuration), pct(@initDuration, 95)'
# DynamoDB throttle
aws cloudwatch get-metric-statistics --namespace AWS/DynamoDB \
--metric-name ThrottledRequests --dimensions Name=TableName,Value=miti99bot-data \
--statistics Sum --start-time $(date -u -d '24 hours ago' +%FT%TZ) \
--end-time $(date -u +%FT%TZ) --period 3600
# Current month spend
aws ce get-cost-and-usage --granularity MONTHLY \
--time-period Start=$(date -u +%Y-%m-01),End=$(date -u +%F) \
--metrics UnblendedCost
```
## Free-tier guardrails
| Resource | Free | Watch when |
|---|---|---|
| Lambda req / GB-s | 1M / 400k | Past 50% mid-month |
| DynamoDB req | 200M | Past 5% (sign of runaway loop) |
| DynamoDB storage | 25 GiB | Past 100 MiB (suspect leaks) |
| EventBridge invocations | 14M | Past 1k/mo (suspect mis-config) |
| CloudWatch Logs ingest | 5 GB | Past 50% mid-month |
| Egress | 100 GB | Past 1 GB (wildly high) |
A `$1` budget alert at 80%/100% catches all of these via cost-side fallout.
+21 -62
View File
@@ -1,8 +1,7 @@
# Deploy: Self-host (Coolify + MongoDB Atlas)
Run `miti99bot` as a long-lived container on [Coolify](https://coolify.io) with
[MongoDB Atlas](https://www.mongodb.com/atlas) (free M0) for storage. This
replaces the AWS Lambda + DynamoDB + EventBridge path.
[MongoDB Atlas](https://www.mongodb.com/atlas) (free M0) for storage.
## Architecture
@@ -14,12 +13,9 @@ replaces the AWS Lambda + DynamoDB + EventBridge path.
NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
```
Same Go binary (`cmd/server`) and module framework as AWS. Three things differ,
all selected automatically from env:
- **Storage** — `mongodb` auto-selected when `MONGO_URL` is set (no `KV_PROVIDER`).
- **Cron** — an in-process scheduler (`internal/cron`) runs unconditionally and
fires each module cron on its `Schedule` (UTC). No EventBridge.
fires each module cron on its `Schedule` (UTC).
- **Transport** — long polling (`b.Start`) is the **only** transport. The bot
opens an outbound connection to Telegram and pulls updates, so there is no
public domain, no `/webhook`, and no webhook secret. The container clears any
@@ -39,10 +35,9 @@ Copy [`.env.example`](../.env.example) → `.env` (gitignored) and fill in.
| `ADMIN_IDS` | optional | CSV of admin ids (renamed from `ADMIN_USER_IDS`) |
| `GEMINI_API_KEY` | optional | only the `twentyq` module needs it |
**Leave UNSET on self-host:** all `*_PARAMETER_NAME` vars (they force an
SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`,
`PORT`, `TELEGRAM_WEBHOOK_SECRET`, `GOLD_VNAPP_API_KEY`, and the
`STOCK/COIN/GOLD *_API_URL` overrides (modules use coded defaults).
**Leave UNSET on self-host:** `KV_PROVIDER`, `PORT`,
`TELEGRAM_WEBHOOK_SECRET`, `GOLD_VNAPP_API_KEY`, and the `STOCK/COIN/GOLD
*_API_URL` overrides (modules use coded defaults).
> Cron runs in-process (`internal/cron`) — there is no `/cron` HTTP route and no
> `CRON_SHARED_SECRET`. The scheduler is the sole trigger; nothing inbound.
@@ -57,11 +52,10 @@ SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`,
> **Accepted trade-off (validated decision).** The Coolify host has no stable
> egress IP, so the Atlas IP allow-list is open to the internet. This widens
> the surface beyond DynamoDB's IAM-gated posture (where the DB was never
> internet-reachable). It is knowingly accepted for self-host. The mandatory
> compensating controls are: (1) strong unique password, (2) least-privilege
> `readWrite`-on-one-db user, (3) the connection string is a secret and is
> never logged (the bot logs only the database name on startup).
> the database surface. The mandatory compensating controls are: (1) strong
> unique password, (2) least-privilege `readWrite`-on-one-db user, (3) the
> connection string is a secret and is never logged (the bot logs only the
> database name on startup).
4. Copy the `mongodb+srv://…` connection string into `MONGO_URL` and put the
db name in `MONGO_DATABASE`.
@@ -72,8 +66,7 @@ SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`,
> expand and are queryable in Compass. The two non-object values are wrapped in a
> named field: lolschedule subscribers under `subscribers` (array) and the daily
> push date under `date`. Concurrency uses the `version` field (optimistic lock);
> `updatedAt` is a BSON Date. The DynamoDB→Mongo migrator writes this shape
> directly, and is idempotent.
> `updatedAt` is a BSON Date.
## 2. Coolify
@@ -108,57 +101,23 @@ SSM/AWS lookup that fails with no AWS creds and bricks startup), `KV_PROVIDER`,
Long polling needs no webhook registration — only the command menu:
```sh
TELEGRAM_BOT_TOKEN=… make telegram-commands-selfhost
TELEGRAM_BOT_TOKEN=… make telegram-commands
```
## Cutover runbook
## Operations
Zero-loss switch from the live AWS Lambda to the Coolify poller. Coordinate
users to pause activity during the brief window.
The live deployment is the Coolify container and MongoDB is the sole system of
record. Keep exactly one replica running. To confirm Telegram is in polling mode:
1. **Deploy the Coolify container but keep it stopped/scaled-to-0.** A running
poller would 409 against the live Lambda webhook and its scheduler would
overlap EventBridge. Use a fresh, empty Atlas DB.
2. **Disable/delete the EventBridge schedule** `miti99bot-lolschedule-daily-push`
(it invokes the Lambda directly, independent of transport). The in-process
scheduler's per-UTC-date idempotency guard is the backup.
3. **`deleteWebhook` (mandatory):**
```sh
TELEGRAM_BOT_TOKEN=… make telegram-deletewebhook-selfhost
```
This buffers incoming updates (Telegram retains ~24h) so nothing is lost, and
releases the webhook so the poller won't 409. After this the Lambda stops
receiving updates.
4. **Migrate + verify** (read-only on DynamoDB — see
[`cmd/migrate-dynamo-to-mongo`](../cmd/migrate-dynamo-to-mongo/README.md)):
```sh
export MONGO_URL=… MONGO_DATABASE=… AWS_PROFILE=miti99bot-migrate
make migrate-dynamo-to-mongo DRY_RUN=1 # review counts
make migrate-dynamo-to-mongo # real run
make migrate-verify # counts must match, exit 0
```
Keep this window short (target minutes).
5. **Start the Coolify container** (1 replica). Its scheduler runs by default
(safe now that EventBridge is off). On startup it `deleteWebhook`s again
(idempotent) and begins polling, draining Telegram's buffered queue.
6. **Confirm:**
```sh
TELEGRAM_BOT_TOKEN=… make telegram-webhook-info-selfhost
```
`url` should be empty and `pending_update_count` should drain toward 0.
Smoke `/ping`, `/stats`, and a coin/stock balance command — migrated state
should be visible from Atlas.
7. **Tear down AWS** once verified — see
[`aws-decommission-runbook.md`](./aws-decommission-runbook.md).
```sh
TELEGRAM_BOT_TOKEN=… make telegram-webhook-info
```
### Rollback
`url` should be empty. If needed, clear the webhook explicitly:
The only clean revert is **before `sam delete`**: stop the poller, then
re-`setWebhook` to the Lambda Function URL (the still-deployed Lambda runs its
own webhook code until teardown). Lossless **only until the first post-cutover
Mongo write** — after that, MongoDB/Coolify is the sole system of record. This
short-window RPO was an explicitly accepted decision; no reverse migrator
exists.
```sh
TELEGRAM_BOT_TOKEN=… make telegram-deletewebhook
```
## Local smoke test
-17
View File
@@ -3,10 +3,6 @@ module github.com/tiennm99/miti99bot
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6
github.com/go-telegram/bot v1.20.0
github.com/robfig/cron/v3 v3.0.1
go.mongodb.org/mongo-driver/v2 v2.7.0
@@ -18,19 +14,6 @@ require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
-34
View File
@@ -4,40 +4,6 @@ cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3 h1:XgjzLEE8CrNYnr4Xmi1W5PfKsKMjp4Pu1rWkJNO43JI=
github.com/aws/aws-sdk-go-v2/service/dynamodb v1.57.3/go.mod h1:r7sfLXEN8RUA89tAHy1E7lCtVOOWIkqVy/FbnUdxW1E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23 h1:3Eo/PBBnjFi1+gYfaL286dpmFSW3mTfodBIybq36Qv4=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.23/go.mod h1:3oh+5xGSd1iuxonVb3Qbm+WJYlbhczT9kbzr6doJLzY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6 h1:0LPJjbSNEDHidGOXa0LfvSVbdn9/GdlJUQTgE0kFpso=
github.com/aws/aws-sdk-go-v2/service/ssm v1.68.6/go.mod h1:SrZAopBP5/lyQ6NBVXKlRp8wPIXhzBCZU98sEozmv8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+5 -6
View File
@@ -25,18 +25,17 @@ var ErrRateLimited = errors.New("ai: rate limited")
var ErrNotConfigured = errors.New("ai: GEMINI_API_KEY not set")
// Client wraps a *genai.Client with the small surface the bot needs. The
// underlying gRPC connection is reused across requests — Lambda cold-start
// budget makes a per-request handshake intolerable.
// underlying gRPC connection is reused across requests so each command does not
// pay a fresh handshake cost.
//
// Safe for concurrent use; *genai.Client is itself goroutine-safe.
type Client struct {
g *genai.Client
}
// NewClient constructs a *Client backed by the Gemini API (not Vertex AI —
// Vertex requires a service-account flow incompatible with the free-tier
// Lambda baseline). A blank apiKey returns ErrNotConfigured so callers
// can decide whether to skip AI-dependent module loading.
// NewClient constructs a *Client backed by the Gemini API. A blank apiKey
// returns ErrNotConfigured so callers can decide whether to skip AI-dependent
// module loading.
func NewClient(ctx context.Context, apiKey string) (*Client, error) {
if strings.TrimSpace(apiKey) == "" {
return nil, ErrNotConfigured
+4 -5
View File
@@ -15,13 +15,12 @@ import (
//
// Why we don't enforce daily caps here: x/time/rate is a token bucket, not
// a fixed-window counter. Per-day caps need a different abstraction; if we
// hit RPD limits in practice we'll add a DynamoDB-backed counter.
// hit RPD limits in practice we'll add a persistent counter.
//
// Memory bound: the buckets map is never evicted. Each entry costs ~120 B.
// On Lambda the container recycles every ~hour, so the map size is bounded
// by "distinct subjects within one container lifetime" — small enough to
// not justify an LRU. Same rationale as keylock.Map; if either ever runs
// outside Lambda for long-lived processes, both will need eviction.
// The bot's expected audience is small enough that the distinct-subject working
// set does not justify an LRU. Same rationale as keylock.Map; add eviction if
// either map starts growing materially in production.
type PerUserLimiter struct {
mu sync.Mutex
buckets map[string]*rate.Limiter
+3 -4
View File
@@ -1,7 +1,6 @@
// Package cron runs module crons in-process for the self-hosted (long-lived
// container) deployment. On AWS, EventBridge Scheduler hit /cron/{name}; off
// AWS there is no external trigger, so this scheduler reads each registered
// cron's Schedule field and fires its handler on time, in UTC.
// Package cron runs module crons in-process for the self-hosted long-lived
// container. It reads each registered cron's Schedule field and fires its
// handler on time, in UTC.
package cron
import (
+2 -3
View File
@@ -8,9 +8,8 @@
// race and drop one write.
//
// Trade-off: the underlying sync.Map grows unboundedly with distinct keys
// (~32 B each). At 1M keys that's ~32 MB — acceptable for the lifetime of
// a Lambda instance, which restarts well before reaching that scale.
// Eviction is intentionally deferred — restart frequency keeps the working set bounded.
// (~32 B each). At the current bot scale, that is acceptable; add eviction if
// production cardinality starts growing materially.
package keylock
import "sync"
+2 -3
View File
@@ -1,7 +1,6 @@
// Package log is a thin facade over stdlib log/slog with a JSON handler
// preconfigured for CloudWatch Logs. Lambda reads stdout line-by-line; with
// a JSON line, CloudWatch Logs picks up `severity`, `message`, and `time` and
// surfaces remaining fields as structured labels for filtering.
// configured for container stdout. JSON lines keep fields structured in the
// hosting log sink.
//
// Why a facade instead of importing slog directly: (1) callers stay
// log-package-agnostic (we can swap to logrus/zap later by editing one file);
+7 -14
View File
@@ -1,16 +1,9 @@
// Package metrics is a tiny in-memory counter store with periodic flush
// to CloudWatch Logs via the project's structured logger.
// Package metrics is a tiny in-memory counter store with periodic flush to the
// project's structured logger.
//
// Why not Prometheus / OpenTelemetry: the project runs on Lambda free
// tier with scale-to-zero. A pull-based exporter would be scraped from
// outside the instance and routinely hit a cold pod, defeating the point.
// Push-based exporters (StatsD, OTLP) require a paid sink.
//
// CloudWatch Logs is already free up to a generous quota and supports
// log-based metrics (count over `jsonPayload.msg=metrics`) for dashboards
// and alerts. Per-instance counters are reset on flush so the log line
// represents a delta, which CloudWatch Logs's count aggregation can sum
// across instances and time windows.
// The project intentionally avoids running a metrics sidecar or external
// exporter. Per-instance counters are reset on flush so each log line
// represents a delta that can be aggregated by the hosting log sink.
package metrics
import (
@@ -24,7 +17,7 @@ import (
// DefaultFlushInterval is how often Run flushes counters to the log. 60s
// keeps log volume modest (1 metrics line per minute per active instance)
// while still surfacing minute-scale traffic shifts in CloudWatch.
// while still surfacing minute-scale traffic shifts.
const DefaultFlushInterval = 60 * time.Second
// Registry holds named counters across three categories: command
@@ -32,7 +25,7 @@ const DefaultFlushInterval = 60 * time.Second
//
// Counters use atomic.Int64 so increments don't lock; the per-name map
// itself is guarded by an RWMutex for the rare add path. Names should be
// short and stable — they become CloudWatch Logs label values.
// short and stable — they become log label values.
type Registry struct {
mu sync.RWMutex
commands map[string]*atomic.Int64
+1 -1
View File
@@ -80,7 +80,7 @@ func (s *conflictOnceStore) PutVersioned(ctx context.Context, key string, expect
s.conflicted = true
competing := NewPortfolio(1)
competing.AddUSD(10)
if err := s.Store.Put(ctx, key, competing); err != nil {
if err := s.Put(ctx, key, competing); err != nil {
return err
}
return storage.ErrConflict
+1 -2
View File
@@ -27,8 +27,7 @@ func (s *state) handleStats(ctx context.Context, b *bot.Bot, update *models.Upda
// Fetch sequentially (not concurrently) so the price client's keep-alive
// connection pool is reused across coins rather than opening N simultaneous
// TLS handshakes — the latter thrashes the CPU-constrained Lambda and times
// out. The reply-reserved sub-context bounds the whole loop so the final
// TLS handshakes. The reply-reserved sub-context bounds the whole loop so the final
// Reply keeps its budget; a slow/failed provider degrades to "(price
// unavailable)" instead of failing the summary.
fetchCtx, cancel := chathelper.FetchContext(ctx)
+1 -1
View File
@@ -88,7 +88,7 @@ func (s *conflictOnceStore) PutVersioned(ctx context.Context, key string, expect
s.conflicted = true
competing := NewPortfolio(1)
competing.AddVND(10)
if err := s.PortfolioStore.Put(ctx, key, competing); err != nil {
if err := s.Put(ctx, key, competing); err != nil {
return err
}
return storage.ErrConflict
+1 -1
View File
@@ -10,7 +10,7 @@ import (
)
// XAU/USD provider defaults. All three are free, keyless, and verified to
// answer datacenter IPs (goldprice.org was dropped: it 403s AWS/cloud IPs).
// answer datacenter IPs (goldprice.org was dropped: it 403s cloud/datacenter IPs).
const (
goldAPIDefaultURL = "https://api.gold-api.com/price/XAU"
swissquoteDefaultURL = "https://forex-data-feed.swissquote.com/public-quotes/bboquotes/instrument/XAU/USD"
+2 -3
View File
@@ -45,9 +45,8 @@ type VNAppMobClient struct {
}
// NewVNAppMobClientFromEnv creates a client reading GOLD_VNAPP_API_URL and
// GOLD_VNAPP_API_KEY from the environment. The API key env var is intended
// for local dev or SSM injection; when empty the client refreshes the key
// automatically via the VNAppMob refresh endpoint.
// GOLD_VNAPP_API_KEY from the environment. When the key is empty, the client
// refreshes it automatically via the VNAppMob refresh endpoint.
func NewVNAppMobClientFromEnv(coll storage.Collection) *VNAppMobClient {
return &VNAppMobClient{
BaseURL: strings.TrimSpace(os.Getenv("GOLD_VNAPP_API_URL")),
-7
View File
@@ -20,13 +20,6 @@ func newLoldleConfig() ConfigStore {
return storage.Typed[roundConfig](storage.NewMemoryProvider().Collection("loldle"))
}
// newLoldleStores returns all three stores backed by the same collection
// (disjoint key prefixes: "game:", "stats:", "config:").
func newLoldleStores() (GameStore, StatsStore, ConfigStore) {
c := storage.NewMemoryProvider().Collection("loldle")
return storage.Typed[gameState](c), storage.Typed[stats](c), storage.Typed[roundConfig](c)
}
func TestGameState_StartedAtNullByDefault(t *testing.T) {
g := gameState{Target: "Aatrox", Guesses: []string{}}
b, err := json.Marshal(g)
+3 -4
View File
@@ -39,15 +39,14 @@ const (
// upstream call fails outright.
staleMaxAge = 60 * 60 * time.Second
// httpTimeout: keep upstream calls bounded so a hung lolesports edge
// can't hold a Lambda instance.
// can't hold a worker goroutine indefinitely.
httpTimeout = 8 * time.Second
)
// Team is one side of a match. JSON shape matches the lolesports response.
// bson tags mirror the json names exactly: this tree is persisted inside
// cacheRecord, and the DynamoDB→Mongo migrator preserves the original (camelCase)
// JSON keys, so the store must read those keys back verbatim — not the driver's
// lowercased default.
// cacheRecord, so the store must read those keys back verbatim — not the
// driver's lowercased default.
type Team struct {
Name string `json:"name,omitempty" bson:"name,omitempty"`
Code string `json:"code,omitempty" bson:"code,omitempty"`
+3 -6
View File
@@ -81,18 +81,15 @@ func classifyTerminal(err error) terminalKind {
// Must match the regex in internal/server/router.go (^[a-z0-9_]{1,32}$).
const dailyPushCronName = "lolschedule_daily_push"
// dailyPushSchedule drives the in-process scheduler (internal/cron) on
// self-host; it was also the documented EventBridge time on AWS. Cron
// dailyPushSchedule drives the in-process scheduler (internal/cron). Cron
// expression is UTC; 01:00 UTC == 08:00 ICT.
const dailyPushSchedule = "0 1 * * *"
// lastPushDateKey records the UTC date (YYYY-MM-DD) of the most recent
// completed daily push. The handler claims this key before fanning out and
// no-ops if it is already today's date, making the push idempotent per UTC
// date. This defends against every double-fire window — cutover overlap
// (EventBridge still live while the container's scheduler runs), rolling
// deploys that briefly run two containers, and operator misconfiguration —
// none of which a single trigger source can prevent.
// date. This defends against double-fire windows from rolling deploys that
// briefly run two containers or operator misconfiguration.
const lastPushDateKey = "daily_push:last_date"
// telegramRateLimitThreshold is the subscriber count above which we throttle
+1 -1
View File
@@ -181,7 +181,7 @@ func TestRunDailyPush_ForwardsMessageThreadID(t *testing.T) {
// TestRunDailyPush_IdempotentPerDate locks in the double-fire guard: invoking
// the handler twice on the same UTC date sends each subscriber exactly one
// digest. Defends against cutover overlap, rolling-deploy overlap, and operator
// digest. Defends against rolling-deploy overlap and operator
// misconfiguration (all double-fire windows the daily push must survive).
func TestRunDailyPush_IdempotentPerDate(t *testing.T) {
s := newTestState(t)
+1 -1
View File
@@ -93,7 +93,7 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa
}
// handleSubscribe is /lolschedule_subscribe — opt the chat into the daily
// digest delivered by the EventBridge Scheduler cron handler.
// digest delivered by the in-process cron handler.
func (s *state) handleSubscribe(ctx context.Context, b *bot.Bot, update *models.Update) error {
msg := update.Message
if msg == nil {
+1 -1
View File
@@ -57,7 +57,7 @@ func pairKey(cmd string, id int64) string {
// username, also increments user:<id> (refreshing the cached username) and
// pair:<cmd>:<id>. Errors are logged and swallowed; concurrent invocations of
// the same (cmd, user) may lose updates — stats are best-effort. A future
// atomic increment (e.g. DynamoDB UpdateItem ADD) would close the race.
// backend atomic increment would close the race.
func (c *counter) Inc(ctx context.Context, name string, update *models.Update) {
var (
userID int64
-7
View File
@@ -16,13 +16,6 @@ func newWordleStats() StatsStore {
return storage.Typed[Stats](storage.NewMemoryProvider().Collection("wordle"))
}
// newWordleStores returns a games + stats store backed by the same collection
// (disjoint key prefixes: "game:" vs "stats:").
func newWordleStores() (GameStore, StatsStore) {
c := storage.NewMemoryProvider().Collection("wordle")
return storage.Typed[GameState](c), storage.Typed[Stats](c)
}
func TestStats_DefaultLastResultAtIsNull(t *testing.T) {
// Go's *int64 must marshal as null when nil, so unplayed accounts emit
// `"lastResultAt": null` and the field stays distinguishable from
+3 -3
View File
@@ -2,9 +2,9 @@ package server
import "net/http"
// HealthHandler answers GET / with a stable string so Lambda's HTTP probe
// and any uptime monitor can distinguish "process up" from "process listening
// but routing broken".
// HealthHandler answers GET / with a stable string so the container health
// monitor can distinguish "process up" from "process listening but routing
// broken".
func HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
+1 -1
View File
@@ -78,7 +78,7 @@ func Typed[T any](c Collection) DocStore[T] {
case *memoryCollection:
return &memoryDocStore[T]{c: h}
case invalidCollection:
return invalidDocStore[T]{name: h.name}
return invalidDocStore[T](h)
default:
panic(fmt.Sprintf("storage: unknown collection type %T", c))
}
-50
View File
@@ -1,50 +0,0 @@
package storage
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/dynamodb"
)
// dynamoDBHTTPTimeout caps individual SDK HTTP calls. DynamoDB on-demand is
// fast (typically <50ms); a 10s budget absorbs cold-start TLS handshake on
// Lambda + retries without hiding pathological hangs.
const dynamoDBHTTPTimeout = 10 * time.Second
// NewDynamoDBClient constructs a DynamoDB client using AWS standard credential
// resolution: Lambda execution role → env vars → shared config. Region is
// resolved by the SDK from AWS_REGION (set by Lambda) or AWS_DEFAULT_REGION.
//
// The caller may pass a non-empty endpoint override (e.g. "http://localhost:8000")
// to point at DynamoDB Local for tests. An empty endpoint uses the AWS default.
func NewDynamoDBClient(ctx context.Context, endpoint string) (*dynamodb.Client, error) {
httpClient := &http.Client{Timeout: dynamoDBHTTPTimeout}
loadOpts := []func(*config.LoadOptions) error{
config.WithHTTPClient(httpClient),
}
cfg, err := config.LoadDefaultConfig(ctx, loadOpts...)
if err != nil {
return nil, fmt.Errorf("storage: load AWS config: %w", err)
}
clientOpts := []func(*dynamodb.Options){}
if endpoint != "" {
clientOpts = append(clientOpts, func(o *dynamodb.Options) {
o.BaseEndpoint = aws.String(endpoint)
})
}
return dynamodb.NewFromConfig(cfg, clientOpts...), nil
}
// DynamoDBEndpointFromEnv returns the override endpoint for tests / local dev.
// Empty string means "use AWS default endpoint."
func DynamoDBEndpointFromEnv() string {
return os.Getenv("DYNAMODB_LOCAL_URL")
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// mongoLocalSetup connects to a local MongoDB and returns a fresh, uniquely
// named database plus cleanup. Tests skip if MONGODB_TEST_URL is unset so CI
// without a Mongo container still builds (mirrors the DynamoDB Local gating).
// without a Mongo container still builds.
func mongoLocalSetup(t *testing.T) (*mongo.Database, func()) {
t.Helper()
uri := os.Getenv("MONGODB_TEST_URL")
+1 -1
View File
@@ -23,7 +23,7 @@ const webhookDeleteTimeout = 10 * time.Second
// response body — decoded as "unexpected end of JSON input" — so the webhook is
// never actually removed and getUpdates keeps returning 409. A GET with no body
// sidesteps that request shape. Pending updates are intentionally kept (the API
// default) so the long poller drains the buffered queue for a lossless cutover.
// default) so the long poller drains any buffered queue.
func DeleteWebhook(ctx context.Context, token string) error {
return deleteWebhookAt(ctx, telegramAPIBase, token)
}
-24
View File
@@ -1,24 +0,0 @@
version = 0.1
[default.global.parameters]
stack_name = "miti99bot"
region = "ap-southeast-1"
[default.deploy.parameters]
region = "ap-southeast-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = true
fail_on_empty_changeset = false
resolve_s3 = true
s3_prefix = "miti99bot"
# Secrets MUST live in SSM Parameter Store (see aws/README.md). Never put
# them here — this file is committed.
parameter_overrides = "StackEnv=\"prod\" ModulesCSV=\"util,misc,wordle,loldle,lolschedule,twentyq,stock,stats,gold,coin\" BotOwnerID=\"1064111334\" AdminUserIDs=\"1064111334\" LambdaAdapterLayerArn=\"arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25\" AlertEmail=\"minhtienit99@gmail.com\""
image_repositories = []
[default.validate.parameters]
lint = true
[default.build.parameters]
# We build Go ourselves via `make build-lambda`. SAM build only stages.
use_container = false
-330
View File
@@ -1,330 +0,0 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
miti99bot: Telegram bot on AWS Lambda (Go ZIP + LWA) with DynamoDB KV
+ EventBridge cron + SSM Parameter Store secrets. Strict free-tier deploy.
Parameters:
StackEnv:
Type: String
Default: prod
AllowedValues: [dev, prod]
Description: Environment suffix used in SSM parameter paths.
ModulesCSV:
Type: String
Default: util,misc,wordle,loldle,lolschedule,twentyq,stock,coin,stats
Description: Comma-separated module names enabled at runtime (matches MODULES env).
BotOwnerID:
Type: String
Default: ""
Description: Telegram numeric user ID with bot-owner privileges. Empty disables Private/Protected commands.
AdminUserIDs:
Type: String
Default: ""
Description: Comma-separated Telegram user IDs allowed to use admin commands.
StockIncomeEventsAPIURL:
Type: String
Default: "https://restv2.fireant.vn"
Description: FireAnt REST API base URL. Defaults to https://restv2.fireant.vn when omitted.
StockIncomeEventsAPITokenParameterName:
Type: String
Default: ""
Description: Optional SSM SecureString parameter name containing bearer token for FireAnt REST API.
GoldPriceAPIURL:
Type: String
Default: ""
Description: Optional gold spot price API URL override. Empty uses the built-in GoldPrice.org endpoint.
GoldFXAPIURL:
Type: String
Default: ""
Description: Optional USD/VND FX API URL override. Empty uses the built-in ExchangeRate-API open endpoint.
GoldVNAppAPIURL:
Type: String
Default: ""
Description: Optional VNAppMob API base URL override. Empty uses https://api.vnappmob.com.
GoldVNAppAPIKeyParameterName:
Type: String
Default: ""
Description: Optional SSM SecureString parameter name containing a pre-issued VNAppMob JWT API key.
CoinBinanceAPIURL:
Type: String
Default: ""
Description: Optional Binance ticker price API URL override. Empty uses the public market-data endpoint https://data-api.binance.vision/api/v3/ticker/price.
CoinCoinbaseAPIURL:
Type: String
Default: ""
Description: Optional Coinbase exchange-rates API URL override. Empty uses the built-in public endpoint.
CoinCoinGeckoAPIURL:
Type: String
Default: ""
Description: Optional CoinGecko simple price API URL override. Empty uses the built-in public endpoint.
# AWS Lambda Web Adapter ARM64 layer ARN. Pin a specific version so deploys
# are reproducible. Bump by checking the latest at:
# https://github.com/awslabs/aws-lambda-web-adapter/releases
LambdaAdapterLayerArn:
Type: String
Default: arn:aws:lambda:ap-southeast-1:753240598075:layer:LambdaAdapterLayerArm64:25
Description: AWS Lambda Web Adapter layer ARN for the deploy region (ARM64).
AlertEmail:
Type: String
Default: ""
Description: Email for $1 budget alert. Leave empty to skip the budget resource.
# SSM holds the canonical value; CI fetches and passes via --parameter-overrides
# because EventBridge Connection's ApiKeyValue is consumed at stack-update time
# and stored in a service-linked secret (no per-invoke SSM fetch). NoEcho keeps
# the value out of CFN events / console / drift detection.
CronSharedSecret:
Type: String
NoEcho: true
Default: ""
Description: X-Cron-Token header value the EventBridge Rule presents to /cron/{name}. Must match the SSM-stored value the Lambda loads at cold start.
Conditions:
HasAlertEmail: !Not [!Equals [!Ref AlertEmail, ""]]
Globals:
Function:
Runtime: provided.al2023
Architectures: [arm64]
MemorySize: 256
Timeout: 30
Tracing: Active
LoggingConfig:
LogFormat: JSON
ApplicationLogLevel: INFO
SystemLogLevel: WARN
Resources:
# --- Storage --------------------------------------------------------------
BotTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: !Sub "${AWS::StackName}-data"
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- { AttributeName: pk, AttributeType: S }
- { AttributeName: sk, AttributeType: S }
KeySchema:
- { AttributeName: pk, KeyType: HASH }
- { AttributeName: sk, KeyType: RANGE }
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: false # paid feature; off for free tier
Tags:
- { Key: app, Value: miti99bot }
- { Key: env, Value: !Ref StackEnv }
# --- Compute --------------------------------------------------------------
BotFunctionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${AWS::StackName}"
RetentionInDays: 7
# Lambda emits a synthetic REPORT line at the end of every invocation.
# On a cold start that line includes "Init Duration: <ms>". This filter
# parses that field into a custom metric so the AWS-port plan's "P95 < 1.5s"
# cold-start abort criterion is observable from day one.
ColdStartMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref BotFunctionLogGroup
FilterPattern: '[report="REPORT", reqid_label="RequestId:", reqid, dur_label="Duration:", dur, dur_unit="ms", bill_label="Billed", bill_dur_label, bill_dur, bill_unit, mem_label, mem_size_label, mem_size, mem_unit, max_label="Max", max_used_label="Memory", max_used_label2="Used:", max_used, max_used_unit, init_label="Init", init_dur_label="Duration:", init_dur, init_unit="ms"]'
MetricTransformations:
- MetricName: ColdStartInitDuration
MetricNamespace: miti99bot
MetricValue: $init_dur
Unit: Milliseconds
BotFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: !Ref AWS::StackName
CodeUri: build/lambda/
Handler: bootstrap
Layers:
- !Ref LambdaAdapterLayerArn
LoggingConfig:
LogGroup: !Ref BotFunctionLogGroup
Environment:
Variables:
PORT: "8080"
AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap
READINESS_CHECK_PATH: /
# ---- App config (non-secret) ----
KV_PROVIDER: dynamodb
DYNAMODB_TABLE: !Ref BotTable
MODULES: !Ref ModulesCSV
BOT_OWNER_ID: !Ref BotOwnerID
ADMIN_USER_IDS: !Ref AdminUserIDs
STOCK_INCOME_EVENTS_API_URL: !Ref StockIncomeEventsAPIURL
STOCK_INCOME_EVENTS_API_TOKEN_PARAMETER_NAME: !Ref StockIncomeEventsAPITokenParameterName
GOLD_PRICE_API_URL: !Ref GoldPriceAPIURL
GOLD_FX_API_URL: !Ref GoldFXAPIURL
GOLD_VNAPP_API_URL: !Ref GoldVNAppAPIURL
GOLD_VNAPP_API_KEY_PARAMETER_NAME: !Ref GoldVNAppAPIKeyParameterName
COIN_BINANCE_API_URL: !Ref CoinBinanceAPIURL
COIN_COINBASE_API_URL: !Ref CoinCoinbaseAPIURL
COIN_COINGECKO_API_URL: !Ref CoinCoinGeckoAPIURL
# ---- Secrets (fetched from Parameter Store at Lambda cold start) ----
TELEGRAM_BOT_TOKEN_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-bot-token"
TELEGRAM_WEBHOOK_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/telegram-webhook-secret"
GEMINI_API_KEY_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/gemini-api-key"
CRON_SHARED_SECRET_PARAMETER_NAME: !Sub "/miti99bot/${StackEnv}/cron-shared-secret"
FunctionUrlConfig:
AuthType: NONE
InvokeMode: BUFFERED
Cors:
AllowOrigins: ["*"] # Telegram doesn't send CORS; safe default
AllowMethods: ["POST", "GET"]
AllowHeaders: ["*"]
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref BotTable
- Statement:
- Effect: Allow
Action:
- ssm:GetParameter
- ssm:GetParameters
Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/miti99bot/${StackEnv}/*"
# --- Cron -----------------------------------------------------------------
#
# Why EventBridge Scheduler + direct Lambda invoke with a synthetic
# Function-URL-v2 event in Input:
#
# 1. CloudFormation's AWS::Scheduler::Schedule has no schema slot for HTTPS
# universal-target invocation, so we can't have Scheduler POST to the
# Function URL the usual way.
# 2. The legacy AWS::Events::Rule + ApiDestination path works but bills
# $0.20 per million ApiDestination invocations (no free tier).
# 3. Scheduler → Lambda direct-invoke is fully free-tier (14M/mo). Lambda
# Web Adapter normally expects an HTTP-shaped event; we synthesise one
# in Target.Input so LWA proxies it to the local Go server as if it
# came from the Function URL. The /cron/{name} handler is unchanged.
#
# Trade-off accepted: the X-Cron-Token value is embedded plain in the
# schedule's Input and visible to anyone with scheduler:GetSchedule on
# this schedule. Mitigation: CronSharedSecret remains a NoEcho CFN
# parameter (out of template source + stack events), and rotation =
# SSM update + redeploy (same workflow as before).
CronDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: !Sub "${AWS::StackName}-cron-dlq"
MessageRetentionPeriod: 1209600 # 14 days
# Role Scheduler assumes to invoke the Lambda and write to the DLQ.
# lambda:InvokeFunction is the action for direct invoke (vs InvokeFunctionUrl
# which is HTTPS-only and Scheduler can't use via CFN anyway).
SchedulerExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: scheduler.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: cron-invoke-lambda
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt BotFunction.Arn
- Effect: Allow
Action: sqs:SendMessage
Resource: !GetAtt CronDLQ.Arn
# cron(0 1 * * ? *) is 01:00 UTC = 08:00 ICT — matches dailyPushSchedule
# in internal/modules/lolschedule/cron.go.
#
# Input is a minimal Lambda Function URL v2 event payload. LWA detects this
# shape via requestContext.http and proxies it to the local :8080 server
# exactly as if the Function URL had been hit over HTTPS. The Go router
# reads X-Cron-Token from headers (case-insensitive) and routes by rawPath.
#
# $default and $context literals are not !Sub interpolation targets (no
# matching logical ID), so they pass through verbatim. Only ${CronSharedSecret}
# is interpolated.
LolscheduleDailyPushSchedule:
Type: AWS::Scheduler::Schedule
Properties:
Name: !Sub "${AWS::StackName}-lolschedule-daily-push"
Description: Fires lolschedule daily-push handler at 01:00 UTC (08:00 ICT)
ScheduleExpression: "cron(0 1 * * ? *)"
ScheduleExpressionTimezone: UTC
FlexibleTimeWindow: { Mode: "OFF" } # quoted: bare OFF → YAML 1.1 boolean false → EarlyValidation rejects
State: ENABLED
Target:
Arn: !GetAtt BotFunction.Arn
RoleArn: !GetAtt SchedulerExecutionRole.Arn
RetryPolicy:
MaximumRetryAttempts: 2
MaximumEventAgeInSeconds: 600
DeadLetterConfig:
Arn: !GetAtt CronDLQ.Arn
Input: !Sub |
{"version":"2.0","routeKey":"$default","rawPath":"/cron/lolschedule_daily_push","rawQueryString":"","headers":{"x-cron-token":"${CronSharedSecret}","content-type":"application/json","user-agent":"aws-scheduler"},"requestContext":{"http":{"method":"POST","path":"/cron/lolschedule_daily_push","protocol":"HTTP/1.1","sourceIp":"127.0.0.1","userAgent":"aws-scheduler"},"requestId":"scheduler-invoke","stage":"$default","time":"00:00:00","timeEpoch":0,"routeKey":"$default"},"body":"","isBase64Encoded":false}
# --- Cost guard -----------------------------------------------------------
MonthlyBudget:
Type: AWS::Budgets::Budget
Condition: HasAlertEmail
Properties:
Budget:
BudgetName: !Sub "${AWS::StackName}-monthly"
BudgetLimit: { Amount: '1', Unit: USD }
TimeUnit: MONTHLY
BudgetType: COST
NotificationsWithSubscribers:
- Notification:
ComparisonOperator: GREATER_THAN
NotificationType: ACTUAL
Threshold: 80
ThresholdType: PERCENTAGE
Subscribers:
- { Address: !Ref AlertEmail, SubscriptionType: EMAIL }
- Notification:
ComparisonOperator: GREATER_THAN
NotificationType: ACTUAL
Threshold: 100
ThresholdType: PERCENTAGE
Subscribers:
- { Address: !Ref AlertEmail, SubscriptionType: EMAIL }
Outputs:
FunctionUrl:
Description: Public Function URL — set this as the Telegram webhook
Value: !GetAtt BotFunctionUrl.FunctionUrl
TableName:
Description: DynamoDB table name (also exposed to the Lambda via DYNAMODB_TABLE)
Value: !Ref BotTable
LogGroup:
Description: CloudWatch log group for the bot Lambda
Value: !Ref BotFunctionLogGroup
CronDLQArn:
Description: ARN of the cron dead-letter queue
Value: !GetAtt CronDLQ.Arn