From 7eb4977b99f59f6ca1b1d017b2f93b293fddfcc3 Mon Sep 17 00:00:00 2001 From: yash Date: Thu, 26 Feb 2026 16:30:56 +0300 Subject: [PATCH] flake --- CLAUDE.md | 71 +++++++ NIX.md | 204 +++++++++++++++++++ flake.lock | 61 ++++++ flake.nix | 187 +++++++++++++++++ internal/config/config.go | 2 +- internal/repository/postgresql/postgresql.go | 17 +- 6 files changed, 537 insertions(+), 5 deletions(-) create mode 100644 CLAUDE.md create mode 100644 NIX.md create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f609e44 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Run the app (requires CONFIG_PATH env var) +make run +# or explicitly: +CONFIG_PATH=./internal/config/local.yml go run ./cmd/app/main.go + +# Build binary +go build -o crypto_alert_bot ./cmd/app/main.go + +# Start PostgreSQL via Docker +docker compose up -d + +# Create a new migration (NAME=migration_name) +make migrate_create NAME=add_alerts_user_id +# Edit the generated .up.sql and .down.sql in internal/repository/postgresql/migrations/ +# Migrations run automatically on startup — no manual migrate up needed +``` + +There are no tests in this codebase. + +## Architecture + +Go Telegram bot that monitors cryptocurrency prices from Bybit and notifies users when price thresholds are hit. + +### Layer structure (clean architecture) + +``` +cmd/app/main.go → entrypoint: wires config, logger, storage, usecase, bot, alerter +internal/config/ → Config struct loaded from YAML via CONFIG_PATH env var +internal/entities/ → domain types: User, Pair, Price, Alert, Candle (no business logic) +internal/provider/ → Provider interface + Bybit REST implementation +internal/repository/ → Storage interface + PostgreSQL implementation (pgxpool) +internal/usecase/ → business logic; depends on repository.Storage interface +internal/service/alerter/ → background price-checking service; fires and disables alerts +internal/bot/telegram/ → Telegram bot: command handlers, inline keyboards, multi-step flows +internal/logger/ → wraps charmbracelet/log returning *slog.Logger +``` + +### Key design decisions + +- **Config**: YAML at `$CONFIG_PATH`. Local dev config at `internal/config/local.yml`. Sections: `logger`, `postgresql`, `telegram`, `providers.bybit`. +- **Migrations**: Embedded via `go:embed` in `internal/repository/postgresql/migrations/embed.go`, applied automatically on startup via `golang-migrate`. +- **Alerter service** (`internal/service/alerter/`): Runs a goroutine on a 1-minute ticker. On each tick, it fetches OHLC candles (not live price) for the gap since `lastCheckedAt`, using `selectCandleInterval` to pick the coarsest interval that fits in one Bybit request (≤ 1000 candles). Alerts trigger if any candle's High/Low crosses the target. `lastCheckedAt` is persisted to `alerter_state` so missed candles are rechecked after restarts. +- **In-memory alert cache** (`alertsCache`): Keyed by `AlertID` and `InstrumentID`. Only instruments with cached alerts have candles fetched. Cache is loaded from DB on startup via `LoadAlerts`, and kept in sync via `AddAlert`/`RemoveAlert` when the bot creates/removes alerts. +- **Circular dependency** between `Bot` and `Alerter`: resolved by constructing both independently and injecting `Alerter` into `Bot` via `bot.SetAlerter(al)` after both are created. `Bot` implements the `alerter.Notifier` interface (`NotifyAlert`), which the `Alerter` uses to send Telegram messages when an alert fires. +- **Alert condition auto-detection**: When a user sets a target price, the condition (`above`/`below`) is inferred automatically — if target ≥ current ask → `above`, else → `below`. Candle High is checked for `above`, candle Low for `below`. +- **Bot user state**: Per-user `userState` (protected by `sync.Mutex`) tracks multi-step flows (`stepAddAlertPrice`, `stepEditAlertPrice`). State is reset on completion, error, or `/cancel`. +- **Decimal arithmetic**: Prices use `github.com/shopspring/decimal` to avoid floating-point precision issues. Alert prices are stored as `text` in PostgreSQL. +- **Storage interface** (`internal/repository/repository.go`): Decouples usecase layer from PostgreSQL; implement this to swap databases. + +### Database schema + +- `users` — Telegram users (UUID PK, unique `telegram_id bigint`) +- `currency` — currency symbols (e.g. BTC, USDT); pre-seeded in migration +- `instrument` — trading pairs (base + quote currency FK); pre-seeded with BTC/USDT, ETH/USDT, SOL/USDT +- `alert` — price alerts per user/instrument with `active` flag and `alert_condition` enum; deactivated when triggered +- `alerter_state` — single-row table storing `last_alert_check timestamptz`; persists the timestamp across restarts so the alerter can fetch candles for any missed interval + +### Bot commands / flows + +- `/start` — register user +- `/instruments` (or "Instruments" button) — list available trading pairs +- `/add_alert` (or "Add Alert" button) — multi-step: select instrument → enter price → alert created +- `/my_alerts` (or "My Alerts" button) — list active alerts with inline Edit/Remove buttons +- `/cancel` — abort current multi-step flow diff --git a/NIX.md b/NIX.md new file mode 100644 index 0000000..4b0ee25 --- /dev/null +++ b/NIX.md @@ -0,0 +1,204 @@ +# NixOS Deployment + +## Flake outputs + +| Output | Description | +|---|---| +| `packages.default` | The `crypto-alert-bot` binary | +| `devShells.default` | Dev shell with Go toolchain, gopls, golangci-lint, go-migrate | +| `nixosModules.default` | NixOS module that runs the bot as a systemd service | + +--- + +## NixOS module + +The module handles all infrastructure automatically: + +- Enables and configures `services.postgresql` +- Creates the `crypto-alert-bot` database and PostgreSQL role +- Connects via Unix socket using peer authentication (no password) +- Creates a dedicated system user `crypto-alert-bot` +- Runs the bot as a hardened systemd service + +The only secret you need to supply is the Telegram bot token, via a file path. + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `enable` | bool | `false` | Enable the service | +| `package` | package | flake default | Override the binary package | +| `telegramTokenFile` | path | — | **Required.** Path to a file containing the bot token | +| `dbName` | string | `"crypto-alert-bot"` | PostgreSQL database name | +| `logLevel` | `debug\|info\|warn\|error` | `"info"` | Log verbosity | +| `logEncoding` | `console\|json` | `"json"` | Log format | +| `logServiceName` | string | `"alert-bot"` | Service name field in log output | +| `bybitBaseUrl` | string | `"https://api.bybit.com"` | Bybit REST API base URL | + +### Minimal configuration + +```nix +# flake.nix of your NixOS config +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + crypto-alert-bot.url = "github:youruser/crypto_alert_bot"; + agenix.url = "github:ryantm/agenix"; + }; + + outputs = { nixpkgs, crypto-alert-bot, agenix, ... }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + crypto-alert-bot.nixosModules.default + agenix.nixosModules.default + ./configuration.nix + ]; + }; + }; +} +``` + +```nix +# configuration.nix +services.crypto-alert-bot = { + enable = true; + telegramTokenFile = config.age.secrets.telegram-token.path; +}; +``` + +--- + +## Encrypting the Telegram token with agenix + +[agenix](https://github.com/ryantm/agenix) encrypts secrets with [age](https://github.com/FiloSottile/age) +and decrypts them at boot using SSH host keys already present on the machine. +Encrypted secret files are safe to commit to your NixOS config repository. + +### 1. Install age and agenix + +```bash +# age — encryption tool +nix shell nixpkgs#age + +# agenix CLI — manages encrypted secret files +nix shell github:ryantm/agenix +``` + +### 2. Collect recipient public keys + +agenix encrypts each secret for one or more recipients. Typical recipients: + +- **SSH host key** of the target machine (used for decryption at boot) +- **Your personal SSH key** (used so you can re-encrypt or rotate secrets from your workstation) + +```bash +# Get the host's SSH public key (run on the target machine or from your config) +cat /etc/ssh/ssh_host_ed25519_key.pub + +# Get your personal SSH public key +cat ~/.ssh/id_ed25519.pub +``` + +### 3. Create secrets.nix + +In the root of your NixOS config repository, create `secrets/secrets.nix`. +This file declares which keys can decrypt each secret — it is **not** sensitive +and should be committed. + +```nix +# secrets/secrets.nix +let + # SSH public key of the target host + host = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... root@myhost"; + + # Your personal SSH public key (for re-encryption from your workstation) + me = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@workstation"; +in +{ + "telegram-token.age".publicKeys = [ host me ]; +} +``` + +### 4. Encrypt the token + +```bash +cd secrets/ + +# agenix will open $EDITOR so you can type the token, then encrypt on save +agenix -e telegram-token.age +``` + +Type (or paste) the raw token value — just the token string, no quotes, no newline: + +``` +1234567890:ABCdefGHIjklMNOpqrSTUvwxYZ +``` + +Save and close the editor. agenix writes `telegram-token.age` — this encrypted +file is safe to commit. + +### 5. Wire the secret into your NixOS config + +```nix +# configuration.nix +{ config, ... }: +{ + # Declare the secret; agenix decrypts it at boot to /run/agenix/telegram-token + age.secrets.telegram-token = { + file = ./secrets/telegram-token.age; + # The service user must be able to read the decrypted file + owner = "crypto-alert-bot"; + mode = "0400"; + }; + + services.crypto-alert-bot = { + enable = true; + telegramTokenFile = config.age.secrets.telegram-token.path; + # Optionally tune logging: + # logLevel = "debug"; + # logEncoding = "console"; + }; +} +``` + +### 6. Deploy + +```bash +nixos-rebuild switch --flake .#myhost +``` + +At boot, agenix decrypts `telegram-token.age` using the host's SSH private key +and places the plaintext at `/run/agenix/telegram-token` (mode 0400, owned by +`crypto-alert-bot`). The bot service reads the token from that path at start time. + +### Rotating the token + +Edit the encrypted file from your workstation with your personal SSH key: + +```bash +cd secrets/ +agenix -e telegram-token.age # opens $EDITOR with decrypted content +# change the value, save, close +git add telegram-token.age && git commit -m "rotate telegram token" +``` + +Then redeploy: + +```bash +nixos-rebuild switch --flake .#myhost +systemctl restart crypto-alert-bot +``` + +--- + +## Development shell + +```bash +nix develop + +# Start the database locally (Docker or Podman) +docker compose up -d + +# Run the bot +CONFIG_PATH=./internal/config/local.yml go run ./cmd/app/main.go +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..bf24a07 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1771848320, + "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..83dcd2a --- /dev/null +++ b/flake.nix @@ -0,0 +1,187 @@ +{ + description = "Crypto alert Telegram bot that monitors Bybit prices"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + packages.default = pkgs.buildGoModule { + pname = "crypto-alert-bot"; + version = "0.1.0"; + src = ./.; + + vendorHash = "sha256-toXJCeMHa61YCFIYtsTl4dime015AF5LlB62QmYVaA8="; + + subPackages = [ "cmd/app" ]; + + meta = with pkgs.lib; { + description = "Telegram bot for Bybit cryptocurrency price alerts"; + mainProgram = "app"; + }; + }; + + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + go + gopls + gotools + golangci-lint + go-migrate # provides the `migrate` CLI + ]; + shellHook = '' + echo "crypto-alert-bot dev shell" + echo " Run: CONFIG_PATH=./internal/config/local.yml go run ./cmd/app/main.go" + ''; + }; + } + ) // { + # NixOS module — import this in your NixOS config to run the bot as a service. + # + # The module manages PostgreSQL automatically (creates the DB and user, + # connects via Unix socket with peer auth). The only settings you must + # provide are the Telegram token file and optionally log/provider tuning. + # + # Minimal example: + # services.crypto-alert-bot = { + # enable = true; + # telegramTokenFile = "/run/secrets/telegram-token"; + # }; + nixosModules.default = { config, lib, pkgs, ... }: + with lib; + let + cfg = config.services.crypto-alert-bot; + svcUser = "crypto-alert-bot"; + + # The config YAML is fully static (no secrets); the Telegram token is + # read from a file at ExecStartPre time and written into a per-run copy. + staticConfig = pkgs.writeText "crypto-alert-bot-base.yml" '' + logger: + service_name: "${cfg.logServiceName}" + encoding: "${cfg.logEncoding}" + level: "${cfg.logLevel}" + postgresql: + address: "/run/postgresql" + user: "${svcUser}" + db_name: "${cfg.dbName}" + telegram: + token: "__TELEGRAM_TOKEN__" + providers: + bybit: + base_url: "${cfg.bybitBaseUrl}" + ''; + + # Runs as root (+ prefix) before the main process. Copies the static + # config into the runtime directory and splices in the Telegram token. + configSetupScript = pkgs.writeShellScript "crypto-alert-bot-setup-config" '' + set -euo pipefail + token=$(< ${lib.escapeShellArg cfg.telegramTokenFile}) + install -m 600 -o ${svcUser} ${staticConfig} /run/crypto-alert-bot/config.yml + sed -i "s|__TELEGRAM_TOKEN__|$token|" /run/crypto-alert-bot/config.yml + ''; + in + { + options.services.crypto-alert-bot = { + enable = mkEnableOption "crypto alert bot"; + + package = mkOption { + type = types.package; + default = self.packages.${pkgs.system}.default; + defaultText = literalExpression "self.packages.\${system}.default"; + description = "The crypto-alert-bot package to use."; + }; + + dbName = mkOption { + type = types.str; + default = svcUser; + description = "PostgreSQL database name (created automatically)."; + }; + + telegramTokenFile = mkOption { + type = types.path; + description = "Path to a file containing the Telegram bot token (e.g. managed by agenix or sops-nix)."; + }; + + logServiceName = mkOption { + type = types.str; + default = "alert-bot"; + description = "Service name printed in log lines."; + }; + + logEncoding = mkOption { + type = types.enum [ "console" "json" ]; + default = "json"; + description = "Log output encoding."; + }; + + logLevel = mkOption { + type = types.enum [ "debug" "info" "warn" "error" ]; + default = "info"; + description = "Minimum log level."; + }; + + bybitBaseUrl = mkOption { + type = types.str; + default = "https://api.bybit.com"; + description = "Bybit REST API base URL."; + }; + }; + + config = mkIf cfg.enable { + # Create a stable system user whose name matches the PostgreSQL role + # so that peer (Unix socket) authentication works without a password. + users.users.${svcUser} = { + isSystemUser = true; + group = svcUser; + description = "Crypto alert bot service user"; + }; + users.groups.${svcUser} = {}; + + # Provision the database and role automatically. + services.postgresql = { + enable = true; + ensureDatabases = [ cfg.dbName ]; + ensureUsers = [{ + name = svcUser; + ensureDBOwnership = true; + }]; + }; + + systemd.services.crypto-alert-bot = { + description = "Crypto Alert Telegram Bot"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "postgresql.service" ]; + requires = [ "postgresql.service" ]; + + serviceConfig = { + # '+' prefix → root, so we can write to the RuntimeDirectory + # before switching to the unprivileged service user. + ExecStartPre = "+${configSetupScript}"; + ExecStart = "${cfg.package}/bin/app"; + Environment = "CONFIG_PATH=/run/crypto-alert-bot/config.yml"; + + User = svcUser; + Group = svcUser; + + RuntimeDirectory = "crypto-alert-bot"; + RuntimeDirectoryMode = "0700"; + + Restart = "on-failure"; + RestartSec = "5s"; + + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + }; + }; + }; + }; + }; +} diff --git a/internal/config/config.go b/internal/config/config.go index dbe1584..b38498b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,7 +33,7 @@ type Bybit struct { type Postgresql struct { Address string `yaml:"address" env-required:"true"` User string `yaml:"user" env-required:"true"` - Password string `yaml:"password" env-required:"true"` + Password string `yaml:"password" env-default:""` // empty for peer auth over Unix socket DBName string `yaml:"db_name" env-required:"true"` } diff --git a/internal/repository/postgresql/postgresql.go b/internal/repository/postgresql/postgresql.go index c2af618..3c3e763 100644 --- a/internal/repository/postgresql/postgresql.go +++ b/internal/repository/postgresql/postgresql.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/config" "gitea.computernetthings.ru/yash/crypto_alert_bot/internal/repository/postgresql/migrations" @@ -22,8 +23,18 @@ type Postgresql struct { db *pgxpool.Pool } +// dsn builds a connection string that supports both TCP (host:port) and Unix +// socket (path starting with "/") addresses. Unix socket mode skips the +// password and uses OS peer authentication instead. +func dsn(cfg *config.Postgresql) string { + if strings.HasPrefix(cfg.Address, "/") { + return fmt.Sprintf("host=%s user=%s dbname=%s sslmode=disable", cfg.Address, cfg.User, cfg.DBName) + } + return fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", cfg.User, cfg.Password, cfg.Address, cfg.DBName) +} + func New(ctx context.Context, log *slog.Logger, cfg *config.Postgresql) (*Postgresql, error) { - pool, err := pgxpool.New(ctx, fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", cfg.User, cfg.Password, cfg.Address, cfg.DBName)) + pool, err := pgxpool.New(ctx, dsn(cfg)) if err != nil { return nil, fmt.Errorf("failed to connect to postgres: %w", err) } @@ -41,9 +52,7 @@ func New(ctx context.Context, log *slog.Logger, cfg *config.Postgresql) (*Postgr } func applyMigrations(cfg *config.Postgresql, log *slog.Logger) error { - dsn := fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable", cfg.User, cfg.Password, cfg.Address, cfg.DBName) - - sqlDB, err := sql.Open("pgx", dsn) + sqlDB, err := sql.Open("pgx", dsn(cfg)) if err != nil { return fmt.Errorf("failed to open sql db for migrations: %w", err) }