diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 74a6ba6..77d0718 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.194.0/containers/go/.devcontainer/base.Dockerfile -# [Choice] Go version: 1, 1.16, 1.17 -ARG VARIANT="1.21" +# [Choice] Go version +ARG VARIANT="1.23" FROM mcr.microsoft.com/vscode/devcontainers/go:1-${VARIANT} # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8e9100b..e367772 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,8 +5,8 @@ "build": { "dockerfile": "Dockerfile", "args": { - // Update the VARIANT arg to pick a version of Go: 1, 1.16, 1.17 - "VARIANT": "1.21", + // Update the VARIANT arg to pick a version of Go + "VARIANT": "1.23", // Options "NODE_VERSION": "lts/*" } diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 41bb2d3..9290eaf 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,28 +8,22 @@ on: jobs: - tests-coverage-reporter: - env: - CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} + sonarcloud: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Pre-run - run: | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - chmod +x ./cc-test-reporter - ./cc-test-reporter before-build + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.17 - + go-version: "1.23" - name: Calculate test coverage - run: go test -coverprofile=c.out ./... - - - name: Post run - run: ./cc-test-reporter after-build format-coverage -t gocov --prefix github.com/dartkron/leetcodeBot/v3 .cover/c.out --exit-code $? - + run: go test -coverprofile=coverage.txt ./... + - uses: SonarSource/sonarcloud-github-action@ffc3010689be73b8e5ae0c57ce35968afd7909e8 # v5.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} tests: runs-on: ubuntu-latest @@ -39,7 +33,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: 1.17 + go-version: "1.23" - name: Test run: go test -v ./... @@ -53,12 +47,12 @@ jobs: fetch-depth: 1 - uses: WillAbides/setup-go-faster@v1.14.0 with: - go-version: "1.19.x" + go-version: "1.23.x" - run: "go test ./..." - run: "go vet ./..." - uses: dominikh/staticcheck-action@v1.3.0 with: - version: "2023.1.7" + version: "2024.1.1" install-go: false deploy: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae4eb44 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +- Run all tests: `go test -v ./...` +- Run a single package's tests: `go test -v ./internal/bot` +- Run a single test: `go test -v ./internal/bot -run TestProcessRequestBody` +- Coverage: `go test -coverprofile=c.out ./...` +- Static checks: `go vet ./...` and `staticcheck ./...` (CI pins staticcheck `2023.1.7`) +- Build bot entrypoint locally: `go build ./cmd/bot` +- Build reminder entrypoint locally: `go build ./cmd/reminder` + +Go version note: `go.mod` declares `go 1.17`, but CI lint runs on 1.19 and the devcontainer uses 1.21. Don't bump the `go` directive without checking CI. + +## Deployment + +Both binaries are deployed as Yandex.Cloud serverless Functions by `.github/workflows/go.yml` on push to `main`. The workflow zips the repo per function (including shared `internal/` and `pkg/`) and runs `deploy/deploy.py`, which uses the Yandex.Cloud SDK to create a new function version and preserves the previous version's runtime, entrypoint, resources, and env vars. There is no separate staging environment — a push to `main` that passes `lint` and `tests` deploys to production. + +Required secrets at deploy time: `SERVICE_ACCOUNT_KEY_ID`, `SERVICE_ACCOUNT_ID`, `SERVICE_ACCOUNT_PRIVATE_KEY`, `TARGET_FUNCTION_ID` (bot), `REMINDER_FUNCTION_ID` (reminder). + +## Runtime environment variables + +- `YDB_DATABASE`, `YDB_ENDPOINT` — Yandex YDB connection. If absent or unreachable, the bot falls back to the LeetCode GraphQL API for tasks; user subscribe/unsubscribe operations return `ErrNoActiveUsersStorage` because there is no user-storage fallback. +- `SENDING_TOKEN` — Telegram bot token used by the reminder function when POSTing to `api.telegram.org`. + +## Architecture + +Two serverless entrypoints share one application core: + +- [cmd/bot/bot.go](cmd/bot/bot.go) — HTTP handler for Telegram webhooks. Reads the request body, hands it to `bot.Application.ProcessRequestBody`, writes the JSON response. +- [cmd/reminder/reminder.go](cmd/reminder/reminder.go) — Cron-triggered handler that calls `bot.Application.SendDailyTaskToSubscribedUsers` once per hour; it fans out Telegram sends in goroutines. + +Both construct `bot.NewApplication(nil)`, which wires a `storage.YDBandFileCacheController` and a `leetcodeclient.LeetCodeGraphQlClient`. + +### Package layout + +- [internal/bot](internal/bot) — Telegram request/response types, command routing (`/getDailyTask`, `/Subscribe`, `/Unsubscribe`, hour-picker like `9:00`), keyboard generation, and the `Application` orchestrator. `GetTodayTaskFromAllPossibleSources` is the single read path for today's task: tries storage first, falls back to the LeetCode API, writes the result back through the storage controller. +- [internal/storage](internal/storage) — `Controller` interface plus `YDBandFileCacheController`, which composes two `tasksStorekeeper` layers (file cache → YDB) and one `usersStorekeeper` (YDB only). Task reads check the cache first and backfill it on miss; task writes go to both layers. User writes always go straight to YDB; there is no user cache. Sentinel errors (`ErrNoSuchTask`, `ErrNoSuchUser`, `ErrUserAlreadySubscribed`, `ErrUserAlreadyUnsubscribed`, `ErrNoActiveUsersStorage`, `ErrNoActiveTasksStorage`) are part of the public contract — callers in `internal/bot` switch on them. +- [internal/common](internal/common) — Domain types (`BotLeetCodeTask`, `User`, `CallbackData`) and `DateID` helpers. A `DateID` is a `uint64` in `YYYYMMDD` form produced by `GetDateID(time.Time)`; it is the primary key everywhere (file cache filename, YDB `dailyQuestion.id`, Telegram inline-keyboard callback payloads). The package also owns Telegram HTML sanitization (`FixTagsAndImages`, `RemoveUnsupportedTags`, `ReplaceImgTagWithA`) — LeetCode content contains tags Telegram rejects, so every task loaded from the API must be run through `FixTagsAndImages` before storage or display. +- [pkg/leetcodeclient](pkg/leetcodeclient) — GraphQL client for leetcode.com. `LeetCodeGraphQlClient` is split into a `graphQlRequester` transport (swappable for tests) and the two GraphQL queries (`dailyCodingQuestionRecords`, `GetQuestion`). `GetDailyTask` = slug lookup + question fetch. +- [tests/mocks](tests/mocks), [pkg/leetcodeclient/mocks](pkg/leetcodeclient/mocks) — testify-style mocks for the HTTP transport and LeetCode client. `tests.ErrBypassTest` is the conventional sentinel for forcing an error path through a mock. + +### File-cache layer + +`fileCache` writes to `/tmp/task_.cache`. On Yandex.Cloud Functions `/tmp` persists only for the lifetime of a single warm container, so the cache is effectively per-instance. This is intentional — YDB is the shared store; the file cache just avoids re-querying YDB on hot instances. + +### YDB schema + +Two tables, `dailyQuestion` and `users`, are expected to exist (see README.md). `users.sendingHour` is the hour-of-day (UTC) the reminder function uses to select recipients; `GetSubscribedUsers` filters by `subscribed = true AND sendingHour = $hour`. + +## Telegram contract notes + +- All outgoing messages use `parse_mode: HTML`, so content must be HTML-safe and use only Telegram's supported tag subset — go through `common.RemoveUnsupportedTags` / `FixTagsAndImages` rather than emitting tags directly. +- Callback payloads are JSON-encoded `common.CallbackData`; the keyboard builder in `common.GetInlineKeyboard` and the callback router in `Application.processCallback` must stay in sync on the `CallbackType` values (`HintReuqest`, `DifficultyRequest`). diff --git a/README.md b/README.md index 92d7c25..278834a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![Maintainability](https://api.codeclimate.com/v1/badges/371cc101c86797eb2e70/maintainability)](https://codeclimate.com/github/dartkron/leetcodeBot/maintainability) -[![Test Coverage](https://api.codeclimate.com/v1/badges/371cc101c86797eb2e70/test_coverage)](https://codeclimate.com/github/dartkron/leetcodeBot/test_coverage) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=dartkron_leetcodeBot&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=dartkron_leetcodeBot) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=dartkron_leetcodeBot&metric=coverage)](https://sonarcloud.io/summary/new_code?id=dartkron_leetcodeBot) # Telegram bot for Leetcode daily tasks **BOT URL**: https://t.me/MyLeetCodeDailybot diff --git a/deploy/deploy.py b/deploy/deploy.py index 8d0c442..8dcb319 100644 --- a/deploy/deploy.py +++ b/deploy/deploy.py @@ -40,7 +40,7 @@ def deployFunction(targetFunctionId: str, archiveName: str, slService, sdk) -> N createOperation = slService.CreateVersion(CreateFunctionVersionRequest( function_id=currentVersion.function_id, - runtime=currentVersion.runtime, + runtime="golang123", description=f'ref {githubRef} commit: {commitSha}', entrypoint=currentVersion.entrypoint, resources=currentVersion.resources, diff --git a/go.mod b/go.mod index bef4a42..35eb834 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/dartkron/leetcodeBot/v3 -go 1.17 +go 1.23 require ( github.com/stretchr/testify v1.7.0 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..f9ff2a1 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,7 @@ +sonar.projectKey=dartkron_leetcodeBot +sonar.organization=sergei-mishin +sonar.sources=. +sonar.exclusions=**/*_test.go,**/vendor/** +sonar.tests=. +sonar.test.inclusions=**/*_test.go +sonar.go.coverage.reportPaths=coverage.txt