Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
}
Expand Down
30 changes: 12 additions & 18 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ./...
Expand All @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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_<dateID>.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`).
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion deploy/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/dartkron/leetcodeBot/v3

go 1.17
go 1.23

require (
github.com/stretchr/testify v1.7.0
Expand Down
7 changes: 7 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -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
Loading