refactor: adopt cross-platform Go development workflow

This commit is contained in:
2026-07-21 09:05:20 +07:00
parent 5a1d889648
commit ae8064fd2b
10 changed files with 154 additions and 288 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
govulncheck ./...
# No DB emulator step: MongoDB integration tests skip gracefully when
# MONGODB_TEST_URL is unset. Run them locally via `make test-mongo`.
# MONGODB_TEST_URL is unset. See README.md for the local Docker command.
- name: go test
env:
# Quiet test logs so real failures stand out.
-1
View File
@@ -25,7 +25,6 @@ deleting commands, update all related surfaces:
- module command registration in `internal/modules/<module>/`
- handler usage text and user-facing error text
- `telegram-commands.json`
- tests for registration, handlers, and command menu behavior
- README/docs when behavior changes are user-visible
-87
View File
@@ -1,87 +0,0 @@
.PHONY: help test test-mongo mongo-local mongo-local-stop vet build run telegram-commands telegram-commands-info telegram-deletewebhook telegram-webhook-info clean
# 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)
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 ------------------------------------------------------------------
test: ## Unit tests (no emulator required)
go test -race -count=1 ./...
# Run MongoDB integration tests against a local Mongo container.
# Override MONGO_PORT if 27017 is taken on your host.
MONGO_PORT ?= 27017
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/... ./internal/modules/lol/... ./internal/modules/stats/...
# ---- Lint / Vet ------------------------------------------------------------
vet: ## go vet
go vet ./...
# ---- Build -----------------------------------------------------------------
build: ## Build the local server binary (host arch)
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o ./bin/server ./cmd/server
# ---- Run -------------------------------------------------------------------
run: ## Run locally (in-memory storage unless MONGO_URL is set)
go run ./cmd/server
# ---- MongoDB local for tests ------------------------------------------------
mongo-local: ## Start MongoDB container on :$(MONGO_PORT) (idempotent)
@if ! docker ps --format '{{.Names}}' | grep -q '^miti99bot-mongo$$'; then \
docker run -d --rm --name miti99bot-mongo -p $(MONGO_PORT):27017 mongo:7; \
echo "MongoDB started on :$(MONGO_PORT)"; \
sleep 2; \
else \
echo "MongoDB already running"; \
fi
mongo-local-stop: ## Stop local MongoDB
-docker stop miti99bot-mongo
# ---- Telegram operations ----------------------------------------------------
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)"; \
curl -sS -X POST "https://api.telegram.org/bot$${TELEGRAM_BOT_TOKEN}/setMyCommands" \
-H 'Content-Type: application/json' \
--data-binary "@$(TELEGRAM_COMMANDS_FILE)"; \
echo
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: ## 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
# ---- Clean -----------------------------------------------------------------
clean: ## Remove local build artifacts
rm -rf build/ bin/ cov.out
+40 -17
View File
@@ -44,46 +44,69 @@ 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/systemstate/ shared `system` collection helper for future startup migrations
compose.yml Coolify self-host stack (single bot service)
telegram-commands.json Manual Telegram command menu source
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
```
## Run locally
In-memory storage (no database required):
In-memory storage requires no database. Set the environment variables for your
shell, then run the server with Go:
```sh
TELEGRAM_BOT_TOKEN=\
MODULES= \
```powershell
# PowerShell
$env:TELEGRAM_BOT_TOKEN = ""
$env:MODULES = ""
go run ./cmd/server
```
The bot uses long polling, so a local run talks to Telegram directly — no `ngrok`, no public URL. Ensure the bot's webhook is unset (the server clears it on startup) or `getUpdates` 409s. The dev bot is created manually; token injected via env vars only.
```sh
# POSIX shells (Linux/macOS)
export TELEGRAM_BOT_TOKEN="…"
export MODULES=""
go run ./cmd/server
```
The bot uses long polling, so a local run talks to Telegram directly — no
`ngrok` or public URL. The server clears any existing webhook on startup. The
dev bot is created manually; its token is injected through the environment.
Persistent MongoDB locally (auto-selected when `MONGO_URL` is set):
```sh
make mongo-local
TELEGRAM_BOT_TOKEN=\
MONGO_URL=mongodb://127.0.0.1:27017 \
MONGO_DATABASE=miti99bot_dev \
go run ./cmd/server
docker run -d --rm --name miti99bot-mongo -p 27017:27017 mongo:7
```
Then set `MONGO_URL=mongodb://127.0.0.1:27017` and
`MONGO_DATABASE=miti99bot_dev` using the shell syntax above before running
`go run ./cmd/server`. Stop the local database with
`docker stop miti99bot-mongo`.
For MongoDB integration tests, start that container and set
`MONGODB_TEST_URL=mongodb://127.0.0.1:27017`:
```powershell
# PowerShell
$env:MONGODB_TEST_URL = "mongodb://127.0.0.1:27017"
$env:LOG_LEVEL = "error"
go test -count=1 ./internal/storage/... ./internal/modules/lol/... ./internal/modules/stats/...
```
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 # MongoDB integration tests against local MongoDB
# POSIX shells (Linux/macOS)
MONGODB_TEST_URL=mongodb://127.0.0.1:27017 LOG_LEVEL=error \
go test -count=1 ./internal/storage/... ./internal/modules/lol/... ./internal/modules/stats/...
```
## Test
```sh
make vet # go vet
make test # full unit suite (no emulator)
make test-mongo # MongoDB integration tests against local Mongo (requires Docker)
go vet ./...
go test -count=1 ./...
go build ./...
```
CI additionally runs the test suite with Go's race detector.
## Deploy
[`docs/deploy-coolify-selfhosted.md`](docs/deploy-coolify-selfhosted.md) covers
+27 -6
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"os/signal"
"runtime/debug"
"strconv"
"strings"
"syscall"
@@ -32,15 +33,35 @@ import (
"github.com/tiennm99/miti99bot/internal/telegram"
)
// gitSHA is the local-build fallback, populated via `-ldflags "-X
// main.gitSHA=<sha>"` (see Makefile). On Coolify the commit comes from the
// SOURCE_COMMIT runtime env instead (resolveCommitSHA prefers it). Empty from
// both sources means deploynotify stays silent.
var gitSHA string
// gitSHA is the local-build fallback read from the VCS metadata that go build
// embeds automatically. On Coolify the commit comes from SOURCE_COMMIT instead
// (resolveCommitSHA prefers it).
var gitSHA = buildCommitSHA()
func buildCommitSHA() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return ""
}
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" {
return shortCommitSHA(setting.Value)
}
}
return ""
}
func shortCommitSHA(value string) string {
value = strings.TrimSpace(value)
if len(value) > 7 {
return value[:7]
}
return value
}
// resolveCommitSHA returns the commit identifier for the deploy notification.
// Coolify injects SOURCE_COMMIT into the container environment at runtime, so
// prefer it; fall back to the ldflags-baked gitSHA for local builds; and when
// prefer it; fall back to Go's embedded VCS revision for local builds; and when
// neither is set, report "unknown" so the owner still gets the startup DM.
func resolveCommitSHA(envSourceCommit string) string {
if s := strings.TrimSpace(envSourceCommit); s != "" {
+10 -1
View File
@@ -20,7 +20,7 @@ func TestResolveCommitSHA(t *testing.T) {
t.Errorf("env present: got %q, want trimmed runtime-sha", got)
}
// Empty env falls back to the ldflags-baked value.
// Empty env falls back to the VCS revision embedded by go build.
if got := resolveCommitSHA(""); got != "baked" {
t.Errorf("env empty: got %q, want baked fallback", got)
}
@@ -32,6 +32,15 @@ func TestResolveCommitSHA(t *testing.T) {
}
}
func TestShortCommitSHA(t *testing.T) {
if got := shortCommitSHA(" 0123456789abcdef "); got != "0123456" {
t.Errorf("full revision: got %q, want %q", got, "0123456")
}
if got := shortCommitSHA("abc123"); got != "abc123" {
t.Errorf("short revision: got %q, want %q", got, "abc123")
}
}
func TestComposeDoesNotOverrideSourceCommit(t *testing.T) {
b, err := os.ReadFile(filepath.Join("..", "..", "compose.yml"))
if err != nil {
+28 -10
View File
@@ -135,11 +135,8 @@ Successful GIF replies include the result behind Telegram spoiler formatting.
## 3. Command menu
The bot registers its Telegram command menu from loaded public modules on
startup. The manual target remains useful for repairs or local experiments:
```sh
TELEGRAM_BOT_TOKEN=… make telegram-commands
```
every startup. The Go module registry is the single source of truth; no separate
command-menu file or manual registration step is required.
## Operations
@@ -147,23 +144,44 @@ 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:
```sh
TELEGRAM_BOT_TOKEN=… make telegram-webhook-info
# POSIX shells (Linux/macOS)
curl "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getWebhookInfo"
```
```powershell
# PowerShell
Invoke-RestMethod "https://api.telegram.org/bot$env:TELEGRAM_BOT_TOKEN/getWebhookInfo"
```
`url` should be empty. If needed, clear the webhook explicitly:
```sh
TELEGRAM_BOT_TOKEN=… make telegram-deletewebhook
# POSIX shells (Linux/macOS)
curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/deleteWebhook" \
--data "drop_pending_updates=false"
```
```powershell
# PowerShell
Invoke-RestMethod -Method Post -Uri "https://api.telegram.org/bot$env:TELEGRAM_BOT_TOKEN/deleteWebhook" -Body @{ drop_pending_updates = "false" }
```
## Local smoke test
```powershell
# PowerShell
Copy-Item .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
docker compose up --build
```
```sh
cp .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
# POSIX shells (Linux/macOS)
cp .env.example .env # fill TELEGRAM_BOT_TOKEN, MONGO_URL, MONGO_DATABASE
docker compose up --build
```
Boot logs should show `storage backend backend=mongodb database=…` (no
connection string), `cron scheduler started`, and `telegram long polling
started`. `curl localhost:8080/` returns `miti99bot ok`. The bot's webhook must
be unset (the container clears it on startup) or `getUpdates` 409s.
started`. A request to `http://localhost:8080/` returns `miti99bot ok` (use
`Invoke-WebRequest` in PowerShell or `curl` in a POSIX shell). The bot's webhook
must be unset (the container clears it on startup) or `getUpdates` 409s.
@@ -0,0 +1,47 @@
# Cross-Platform Go Workflow Journal
## Context
Removed Unix-oriented Make targets so Windows, macOS, and Linux contributors
can use the same standard Go workflow with platform-specific environment syntax.
## What Changed
- Deleted `Makefile` and replaced its development shortcuts with documented
`go test`, `go vet`, `go build`, `go run`, and direct Docker commands.
- Documented Telegram webhook inspection and cleanup with PowerShell
`Invoke-RestMethod` and POSIX `curl` examples.
- Deleted `telegram-commands.json`; the runtime module registry already builds
and registers the command menu on every startup, so the JSON duplicated the
authoritative Go definitions.
- Replaced Makefile linker flags with Go's embedded VCS build metadata and kept
the existing seven-character commit SHA behavior for deploy notifications.
- Updated CI and MongoDB test guidance to point contributors to the portable
README workflow.
## Reflection
Removing the task wrapper makes the repository less convenient for habitual
`make` users, but avoids maintaining shell-specific orchestration and duplicate
command-menu data. Standard Go, Docker, and HTTP tools keep each operation
explicit and work across supported development platforms.
## Decisions
- The Go module registry is the single source of truth for Telegram commands.
- Local binaries obtain their short revision from Go build information; the
deployment-provided `SOURCE_COMMIT` remains preferred at runtime.
- Platform differences are documented only where shell syntax or HTTP tooling
differs.
## Verification
- Passed: `go test ./...`
- Passed: `go vet ./...`
- Passed: `go build ./...`
- Unavailable: `golangci-lint run` because the binary is not installed.
## Next Steps
- Run the lint gate when `golangci-lint` is available.
- Keep command registration tests aligned with future public command changes.
+1 -1
View File
@@ -20,7 +20,7 @@ func mongoLocalSetup(t *testing.T) (*mongo.Database, func()) {
t.Helper()
uri := os.Getenv("MONGODB_TEST_URL")
if uri == "" {
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test (run `make mongo-local` to start the local container)")
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test (see README.md for local MongoDB setup)")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-164
View File
@@ -1,164 +0,0 @@
{
"commands": [
{
"command": "help",
"description": "Show all available commands"
},
{
"command": "ping",
"description": "Health check; replies pong"
},
{
"command": "random",
"description": "Pick one random comma-separated option"
},
{
"command": "wheelofnames",
"description": "Pick one option with wheel GIF when configured"
},
{
"command": "trongtruonghop",
"description": "Phát biểu disclaimer mặc định"
},
{
"command": "tth",
"description": "Alias for /trongtruonghop"
},
{
"command": "trongtruonghopvng",
"description": "Phát biểu disclaimer VNG mặc định"
},
{
"command": "tthvng",
"description": "Alias for /trongtruonghopvng"
},
{
"command": "wordle",
"description": "Classic wordle; guess the 5-letter word"
},
{
"command": "wordle_new",
"description": "Start a new wordle round; active round counts as give-up"
},
{
"command": "wordle_giveup",
"description": "Reveal the current wordle answer"
},
{
"command": "wordle_stats",
"description": "Show your wordle stats"
},
{
"command": "loldle",
"description": "Classic loldle; guess the champion"
},
{
"command": "loldle_giveup",
"description": "Reveal the current loldle answer"
},
{
"command": "loldle_stats",
"description": "Show your loldle stats"
},
{
"command": "lol",
"description": "LoL matches for a date; supports dd, dd-mm, dd/mm, ddmm"
},
{
"command": "lol_tomorrow",
"description": "LoL matches for tomorrow"
},
{
"command": "lol_this_week",
"description": "LoL matches for this week"
},
{
"command": "lol_next_week",
"description": "LoL matches for next week"
},
{
"command": "lol_subscribe",
"description": "Get the daily LoL schedule digest"
},
{
"command": "lol_unsubscribe",
"description": "Stop the daily LoL schedule digest"
},
{
"command": "stock_price",
"description": "Show current VN stock price"
},
{
"command": "stock_topup",
"description": "Top up VND to your stock account"
},
{
"command": "stock_buy",
"description": "Buy VN stock at market price"
},
{
"command": "stock_sell",
"description": "Sell VN stock back to VND"
},
{
"command": "stock_cash_dividend",
"description": "Record cash dividend (VND/share TICKER)"
},
{
"command": "stock_share_dividend",
"description": "Record share dividend (owned:new TICKER)"
},
{
"command": "stock_dividend",
"description": "Record cash and share dividend"
},
{
"command": "stock_portfolio",
"description": "Show stock portfolio with P&L"
},
{
"command": "gold_price",
"description": "Show current SJC gold buy/sell price"
},
{
"command": "gold_topup",
"description": "Top up VND to your gold account"
},
{
"command": "gold_buy",
"description": "Buy gold at SJC sell price (luong)"
},
{
"command": "gold_sell",
"description": "Sell gold at SJC buy price (luong)"
},
{
"command": "gold_portfolio",
"description": "Show gold portfolio with P&L"
},
{
"command": "coin_price",
"description": "Show current crypto price in USD"
},
{
"command": "coin_topup",
"description": "Top up USD to your coin account"
},
{
"command": "coin_buy",
"description": "Spend a USD amount to buy coin"
},
{
"command": "coin_sell",
"description": "Sell enough coin to receive a USD amount"
},
{
"command": "coin_portfolio",
"description": "Show coin portfolio with P&L"
},
{
"command": "stats",
"description": "Stats. Try: /stats users, /stats user <username>, /stats cmd <command>"
}
]
}