diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..77b951b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.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 `2024.1.1`) +- Build bot entrypoint locally: `go build ./cmd/bot` +- Build reminder entrypoint locally: `go build ./cmd/reminder` + +Go version note: `go.mod`, CI, the Yandex runtime, and the devcontainer are currently aligned on Go 1.23 (`golang123` in Yandex.Cloud). Do not change the `go` directive or Yandex runtime without checking `.github/workflows/go.yml`, `.devcontainer/devcontainer.json`, and `deploy/deploy.py` together. + +## Deployment + +Both binaries are deployed as Yandex.Cloud serverless Functions by `.github/workflows/go.yml` on push to `main`. The workflow builds two zip archives, puts the selected entrypoint (`cmd/bot/bot.go` or `cmd/reminder/reminder.go`) at the archive root, includes shared `internal/` and `pkg/`, excludes `*_test.go` files, and runs `deploy/deploy.py`. + +`deploy/deploy.py` uses the Yandex.Cloud SDK to create a new function version. It preserves the previous version's entrypoint, resources, execution timeout, service account, and environment variables, sets runtime `golang123`, and adds/updates `GIT_VERSION` from `GITHUB_REF` and `GITHUB_SHA`. There is no separate staging environment — a push to `main` that passes `lint` and `tests` deploys to production. The `sonarcloud` job is separate and is not a deploy dependency. + +Required secrets at deploy time: `SERVICE_ACCOUNT_KEY_ID`, `SERVICE_ACCOUNT_ID`, `SERVICE_ACCOUNT_PRIVATE_KEY`, `TARGET_FUNCTION_ID` (bot), `REMINDER_FUNCTION_ID` (reminder). SonarCloud also needs `SONAR_TOKEN` for the quality job. + +## Runtime environment variables + +- `YDB_DATABASE`, `YDB_ENDPOINT` — Yandex YDB connection. If absent or unreachable, task reads can fall back to the LeetCode GraphQL API; 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`. If it is empty, the send URL becomes `https://api.telegram.org/bot/sendMessage`, which is useful in tests but not in production. + +## 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, creates a 5-second context, hands it to `bot.Application.ProcessRequestBody`, and writes a JSON Telegram response. +- [cmd/reminder/reminder.go](cmd/reminder/reminder.go) — Cron-triggered handler that calls `bot.Application.SendDailyTaskToSubscribedUsers`; it returns a small Yandex.Function response object and propagates errors as status 500. + +Both construct `bot.NewApplication(nil)`, which wires a `storage.YDBandFileCacheController`, a `leetcodeclient.LeetCodeGraphQlClient`, and a default `http.Client`. Tests often construct `Application` directly inside the same package to inject mocks into its private fields. + +### Package layout + +- [internal/bot](internal/bot) — Telegram request/response types, command routing (`/getDailyTask`, `/Subscribe`, `/Unsubscribe`, hour-picker text 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, sanitizes API content, writes the result through the storage controller, and returns it. +- [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 cache and DB, but cache write failures are logged and tolerated. 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` and tests 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 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. +- User-visible commands are exact string matches. `/Subscribe 7` is not supported; `/Subscribe` only opens the hour keyboard, and the subsequent `H:00` text triggers subscription. +- Main keyboard replies are JSON-encoded into `TelegramResponse.ReplyMarkup`; task hint/difficulty buttons use Telegram inline keyboards. +- 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`). +- Callback handling intentionally reads only from storage and does not fall back to the LeetCode API. That prevents arbitrary callback payloads from making the app fetch many historical tasks. +- `SendDailyTaskToSubscribedUsers` selects users by the current UTC hour and sends messages concurrently. Individual send failures are logged after three attempts and do not fail the whole reminder run. + +### Time and IDs + +`common.GetDateInRightTimeZone` currently returns `time.Now().UTC()`, and reminders also use `time.Now().UTC()` for the subscription hour. The LeetCode JSON date parser uses `America/Los_Angeles` for API date fields, but the app's daily task key is UTC-based. Be careful when changing date logic: `DateID` must remain consistent across storage, cache filenames, callbacks, and tests. + +### YDB layer + +The YDB query executer is a package-level singleton initialized with `sync.Once`, and the connection is also initialized once. It reads `YDB_ENDPOINT` and `YDB_DATABASE` on first connection creation, so tests that stub connection behavior should do it before the first real connection attempt. + +Task persistence stores hints as a JSON string in YDB and maps difficulty through numeric values (`Easy` = 0, `Medium` = 1, `Hard` = 2, unknown = 3). Changing these mappings requires a storage migration or compatibility layer. + +### LeetCode API notes + +The GraphQL transport posts to `https://leetcode.com/graphql` with a browser-like `user-agent` and a currently hardcoded `x-csrftoken` (marked `FIXME` in code). If LeetCode starts rejecting requests, check [pkg/leetcodeclient/http.go](pkg/leetcodeclient/http.go) first. + +The HTTP requester currently reads the response body without checking non-2xx status codes or closing the response body explicitly. Existing tests focus on transport errors and JSON parsing; add coverage before changing this behavior. + +### Testing notes + +- The test suite relies heavily on call journals and exact JSON strings. Small text, keyboard, or field-order changes can require test updates even when behavior is intentionally changed. +- `tests.ErrBypassTest` is used to force error branches through mocks. Keep using it for new tests to match the existing style. +- Tests live beside packages (`internal/bot`, `internal/storage`, `pkg/leetcodeclient`) and sometimes access unexported fields by using the same package name instead of `_test`. diff --git a/CLAUDE.md b/CLAUDE.md index ae4eb44..7dcf00e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,56 +1,3 @@ # 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`). +See [AGENTS.md](AGENTS.md). This repository keeps agent instructions in one canonical file. diff --git a/README.md b/README.md index 278834a..e1b8fb5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ CREATE TABLE `dailyQuestion` `questionId` Uint64, `title` String, `titleSlug` String, + `topicTags` String, PRIMARY KEY (`id`) ); @@ -40,12 +41,18 @@ CREATE TABLE `users` Awaits `YDB_DATABASE` and `YDB_ENDPOINT` environment variables. __If database connection will fail or variables will not set, bot will connect LeetCode API for question data.__ +For existing databases created before topic tags support, add the nullable column: +```sql +ALTER TABLE `dailyQuestion` ADD COLUMN `topicTags` String; +``` + ## Features 1. Can reply with today task. 2. Can send task hints if they are set. 3. Can send task difficulty. -3. Subscribe/Unsubscribe user buttons/commands. -4. Once per hour reminder serverless function send new task to all users who subscribed for this hour. Reminder require `SENDING_TOKEN` environment variable with Telegram API token. +4. Can send task topics. +5. Subscribe/Unsubscribe user buttons/commands. +6. Once per hour reminder serverless function send new task to all users who subscribed for this hour. Reminder require `SENDING_TOKEN` environment variable with Telegram API token. And it's all on the current stage. Plan to add: diff --git a/internal/bot/application.go b/internal/bot/application.go index 335c809..a0fe284 100644 --- a/internal/bot/application.go +++ b/internal/bot/application.go @@ -147,6 +147,16 @@ func (app *Application) processCallback(ctx context.Context, request TelegramReq response.Text = fmt.Sprintf("Hint #%d: %s", callback.Hint+1, task.Hints[callback.Hint]) } else if callback.Type == common.DifficultyRequest { response.Text = fmt.Sprintf("Task difficulty: %s", task.Difficulty) + } else if callback.Type == common.TopicTagsRequest { + if len(task.TopicTags) == 0 { + response.Text = fmt.Sprintf("There are no topics for task %d", callback.DateID) + return response, nil + } + topics := make([]string, len(task.TopicTags)) + for i, tag := range task.TopicTags { + topics[i] = tag.Name + } + response.Text = fmt.Sprintf("Task topics: %s", strings.Join(topics, ", ")) } return response, nil } diff --git a/internal/bot/application_test.go b/internal/bot/application_test.go index 43c40e0..13b6d1d 100644 --- a/internal/bot/application_test.go +++ b/internal/bot/application_test.go @@ -105,15 +105,24 @@ func TestGetMainKeyboard(t *testing.T) { } func getTodaySendMessageString(chatID uint64) string { - template := "{\"method\":\"sendMessage\",\"parse_mode\":\"HTML\",\"chat_id\":%d,\"text\":\"\\u003cstrong\\u003eTest title\\u003c/strong\\u003e\\n\\nTest content\",\"reply_markup\":\"" + - "{\\\"inline_keyboard\\\":[[{\\\"text\\\":\\\"See task on LeetCode website\\\",\\\"url\\\":\\\"https://leetcode.com/problems/6534\\\"}]," + - "[{\\\"text\\\":\\\"Hint 1\\\",\\\"callback_data\\\":\\\"{\\\\\\\"dateID\\\\\\\":\\\\\\\"DATEID_PLACE\\\\\\\",\\\\\\\"callback_type\\\\\\\":0,\\\\\\\"hint\\\\\\\":0}\\\"}," + - "{\\\"text\\\":\\\"Hint 2\\\",\\\"callback_data\\\":\\\"{\\\\\\\"dateID\\\\\\\":\\\\\\\"DATEID_PLACE\\\\\\\",\\\\\\\"callback_type\\\\\\\":0,\\\\\\\"hint\\\\\\\":1}\\\"}]," + - "[{\\\"text\\\":\\\"Hint: Get the difficulty of the task\\\",\\\"callback_data\\\":" + - "\\\"{\\\\\\\"dateID\\\\\\\":\\\\\\\"DATEID_PLACE\\\\\\\",\\\\\\\"callback_type\\\\\\\":1,\\\\\\\"hint\\\\\\\":0}\\\"}]]}\"}" dateID := common.GetDateIDForNow() - template = strings.ReplaceAll(template, "DATEID_PLACE", fmt.Sprintf("%d", dateID)) - return fmt.Sprintf(template, chatID) + task := common.BotLeetCodeTask{ + DateID: dateID, + LeetCodeTask: leetcodeclient.LeetCodeTask{ + QuestionID: 1445, + TitleSlug: "6534", + Title: "Test title", + Content: "Test content", + Hints: []string{"first hint", "Second Hint"}, + Difficulty: "Easy", + }, + } + response := NewTelegramResponse() + response.ChatID = chatID + response.Text = task.GetTaskText() + response.ReplyMarkup = task.GetInlineKeyboard() + bytes, _ := json.Marshal(response) + return string(bytes) } func getTestApp() (*mocks.MockHTTPTRansport, *MockStorageController, *lcclientmocks.MockLeetcodeClient, *Application) { @@ -661,8 +670,13 @@ func TestProcessRequestTaskMessage(t *testing.T) { assert.Nil(t, err, "Unexpected json.Marshal error") responseBytes, err := app.ProcessRequestBody(context.Background(), requestbytes) assert.Nil(t, err, "Unexpected ProcessRequestBody error") - expectedResponse := fmt.Sprintf("{\"method\":\"sendMessage\",\"parse_mode\":\"HTML\",\"chat_id\":1126,\"text\":\"\\u003cstrong\\u003eTest title\\u003c/strong\\u003e\\n\\nTest content\",\"reply_markup\":\"{\\\"inline_keyboard\\\":[[{\\\"text\\\":\\\"See task on LeetCode website\\\",\\\"url\\\":\\\"https://leetcode.com/problems/6534\\\"}],[{\\\"text\\\":\\\"Hint 1\\\",\\\"callback_data\\\":\\\"{\\\\\\\"dateID\\\\\\\":\\\\\\\"%d\\\\\\\",\\\\\\\"callback_type\\\\\\\":0,\\\\\\\"hint\\\\\\\":0}\\\"},{\\\"text\\\":\\\"Hint 2\\\",\\\"callback_data\\\":\\\"{\\\\\\\"dateID\\\\\\\":\\\\\\\"%d\\\\\\\",\\\\\\\"callback_type\\\\\\\":0,\\\\\\\"hint\\\\\\\":1}\\\"}],[{\\\"text\\\":\\\"Hint: Get the difficulty of the task\\\",\\\"callback_data\\\":\\\"{\\\\\\\"dateID\\\\\\\":\\\\\\\"%d\\\\\\\",\\\\\\\"callback_type\\\\\\\":1,\\\\\\\"hint\\\\\\\":0}\\\"}]]}\"}", todayTaskID, todayTaskID, todayTaskID) - assert.Equal(t, responseBytes, []byte(expectedResponse), "Unexprected response bytes") + expectedTelegramResponse := NewTelegramResponse() + expectedTelegramResponse.ChatID = 1126 + expectedTelegramResponse.Text = storageController.tasks[todayTaskID].GetTaskText() + expectedTelegramResponse.ReplyMarkup = storageController.tasks[todayTaskID].GetInlineKeyboard() + expectedResponse, err := json.Marshal(expectedTelegramResponse) + assert.Nil(t, err, "Unexpected json.Marshal error") + assert.Equal(t, responseBytes, expectedResponse, "Unexprected response bytes") } func TestProcessRequestTaskMessageError(t *testing.T) { @@ -803,6 +817,64 @@ func TestProcessRequestTaskDifficulty(t *testing.T) { assert.Equal(t, responseBytes, []byte(expectedResponse), "Unexprected response bytes") } +func TestProcessRequestTaskTopics(t *testing.T) { + _, storageController, _, app := getTestApp() + todayTaskID := common.GetDateIDForNow() + storageController.tasks[todayTaskID] = &common.BotLeetCodeTask{ + DateID: todayTaskID, + LeetCodeTask: leetcodeclient.LeetCodeTask{ + QuestionID: 1445, + TitleSlug: "6534", + Title: "Test title", + Content: "Test content", + Hints: []string{"first hint", "Second Hint"}, + Difficulty: "Easy", + TopicTags: []leetcodeclient.TopicTag{ + {Name: "Array", Slug: "array"}, + {Name: "Simulation", Slug: "simulation"}, + }, + }, + } + request := TelegramRequest{} + request.CallbackQuery.From.ID = 1126 + data, err := common.GetMarshalledCallbackData(todayTaskID, 0, common.TopicTagsRequest) + assert.Nil(t, err, "Unexpected GetMarshalledCallbackData error") + request.CallbackQuery.Data = data + requestbytes, err := json.Marshal(request) + assert.Nil(t, err, "Unexpected json.Marshal error") + responseBytes, err := app.ProcessRequestBody(context.Background(), requestbytes) + assert.Nil(t, err, "Unexpected ProcessRequestBody error") + expectedResponse := "{\"method\":\"sendMessage\",\"parse_mode\":\"HTML\",\"chat_id\":1126,\"text\":\"Task topics: Array, Simulation\",\"reply_markup\":\"\"}" + assert.Equal(t, responseBytes, []byte(expectedResponse), "Unexprected response bytes") +} + +func TestProcessRequestTaskTopicsEmpty(t *testing.T) { + _, storageController, _, app := getTestApp() + taskID := uint64(20210929) + storageController.tasks[taskID] = &common.BotLeetCodeTask{ + DateID: taskID, + LeetCodeTask: leetcodeclient.LeetCodeTask{ + QuestionID: 1445, + TitleSlug: "6534", + Title: "Test title", + Content: "Test content", + Hints: []string{"first hint", "Second Hint"}, + Difficulty: "Easy", + }, + } + request := TelegramRequest{} + request.CallbackQuery.From.ID = 1126 + data, err := common.GetMarshalledCallbackData(taskID, 0, common.TopicTagsRequest) + assert.Nil(t, err, "Unexpected GetMarshalledCallbackData error") + request.CallbackQuery.Data = data + requestbytes, err := json.Marshal(request) + assert.Nil(t, err, "Unexpected json.Marshal error") + responseBytes, err := app.ProcessRequestBody(context.Background(), requestbytes) + assert.Nil(t, err, "Unexpected ProcessRequestBody error") + expectedResponse := "{\"method\":\"sendMessage\",\"parse_mode\":\"HTML\",\"chat_id\":1126,\"text\":\"There are no topics for task 20210929\",\"reply_markup\":\"\"}" + assert.Equal(t, responseBytes, []byte(expectedResponse), "Unexprected response bytes") +} + func TestProcessRequestBrokenBody(t *testing.T) { _, _, _, app := getTestApp() diff --git a/internal/common/common.go b/internal/common/common.go index 8f298fa..bf246a5 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -33,6 +33,8 @@ const ( HintReuqest CallbackType = iota // DifficultyRequest means that callback require only difficulty DifficultyRequest + // TopicTagsRequest means that callback requires task topic tags. + TopicTagsRequest ) // ErrClosedContext universal error about closed context @@ -124,6 +126,20 @@ func (task *BotLeetCodeTask) GetInlineKeyboard() string { }, ) + getTopicTagsCallbackData, err := GetMarshalledCallbackData(task.DateID, 0, TopicTagsRequest) + if err != nil { + fmt.Println("Got error on marshalling callback data:", err) + } + listOfHints = append( + listOfHints, + []inlineButton{ + { + Text: "Hint: Get the topics of the task", + CallbackData: getTopicTagsCallbackData, + }, + }, + ) + inlineKeyboard, err := json.Marshal(map[string][][]inlineButton{"inline_keyboard": listOfHints}) if err != nil { fmt.Println("Error during marshall inlineKeyboard:", err) diff --git a/internal/common/common_test.go b/internal/common/common_test.go index 0b2989b..134d367 100644 --- a/internal/common/common_test.go +++ b/internal/common/common_test.go @@ -1,6 +1,7 @@ package common import ( + "encoding/json" "testing" "time" @@ -126,6 +127,7 @@ func TestGetMarshalledCallbackData(t *testing.T) { {dateID: 10, hintID: 22, dataType: HintReuqest, awaitingResult: "{\"dateID\":\"10\",\"callback_type\":0,\"hint\":22}"}, {dateID: 10, hintID: 0, dataType: HintReuqest, awaitingResult: "{\"dateID\":\"10\",\"callback_type\":0,\"hint\":0}"}, {dateID: 10, hintID: 22, dataType: DifficultyRequest, awaitingResult: "{\"dateID\":\"10\",\"callback_type\":1,\"hint\":0}"}, + {dateID: 10, hintID: 22, dataType: TopicTagsRequest, awaitingResult: "{\"dateID\":\"10\",\"callback_type\":2,\"hint\":0}"}, } for _, testCase := range testCases { result, err := GetMarshalledCallbackData(testCase.dateID, testCase.hintID, testCase.dataType) @@ -155,10 +157,28 @@ func TestGetInlineKeyboard(t *testing.T) { task.TitleSlug = testCase.TitleSlug task.Hints = testCase.Hints result := task.GetInlineKeyboard() - assert.Equal(t, result, testCase.awaitingResult, "Unexpected GetInlineKeyboard response") + assert.Equal(t, result, withTopicTagsButton(t, testCase.awaitingResult, testCase.DateID), "Unexpected GetInlineKeyboard response") } } +func withTopicTagsButton(t *testing.T, keyboard string, dateID uint64) string { + t.Helper() + parsed := map[string][][]inlineButton{} + err := json.Unmarshal([]byte(keyboard), &parsed) + assert.Nil(t, err, "Unexpected old keyboard JSON in test") + callbackData, err := GetMarshalledCallbackData(dateID, 0, TopicTagsRequest) + assert.Nil(t, err, "Unexpected GetMarshalledCallbackData error") + parsed["inline_keyboard"] = append(parsed["inline_keyboard"], []inlineButton{ + { + Text: "Hint: Get the topics of the task", + CallbackData: callbackData, + }, + }) + encoded, err := json.Marshal(parsed) + assert.Nil(t, err, "Unexpected keyboard JSON marshal error") + return string(encoded) +} + func TestFixTagsAndImages(t *testing.T) { task := BotLeetCodeTask{} task.Title = "
TestTitle" diff --git a/internal/storage/ydb.go b/internal/storage/ydb.go index 5ed5a17..3b868a6 100644 --- a/internal/storage/ydb.go +++ b/internal/storage/ydb.go @@ -17,7 +17,7 @@ const ( getTaskQuery = ` DECLARE $dateId AS Uint64; - SELECT title, content, questionId, titleSlug, hints, difficulty + SELECT title, content, questionId, titleSlug, hints, difficulty, topicTags FROM dailyQuestion WHERE id = $dateId; ` @@ -29,9 +29,10 @@ const ( DECLARE $content AS String; DECLARE $hints AS String; DECLARE $difficulty AS Uint8; + DECLARE $topicTags AS String; - REPLACE INTO dailyQuestion (id, questionId, titleSlug, title, content, hints, difficulty) - VALUES ($dateId, $questionId, $titleSlug, $title, $content, $hints, $difficulty); + REPLACE INTO dailyQuestion (id, questionId, titleSlug, title, content, hints, difficulty, topicTags) + VALUES ($dateId, $questionId, $titleSlug, $title, $content, $hints, $difficulty, $topicTags); ` getUserQuery = ` DECLARE $id AS Uint64; @@ -192,11 +193,12 @@ func (y *ydbStorage) getTask(ctx context.Context, dateID uint64) (common.BotLeet titleSlug *string hints *string difficulty *uint8 + topicTags *string ) returnValue := common.BotLeetCodeTask{DateID: dateID} - for res.NextResultSet(ctx, "title", "content", "questionId", "titleSlug", "hints", "difficulty") { + for res.NextResultSet(ctx, "title", "content", "questionId", "titleSlug", "hints", "difficulty", "topicTags") { for res.NextRow() { err = res.Scan( &title, @@ -205,6 +207,7 @@ func (y *ydbStorage) getTask(ctx context.Context, dateID uint64) (common.BotLeet &titleSlug, &hints, &difficulty, + &topicTags, ) if err != nil { break @@ -218,6 +221,12 @@ func (y *ydbStorage) getTask(ctx context.Context, dateID uint64) (common.BotLeet if err != nil { break } + if topicTags != nil && *topicTags != "" { + err = json.Unmarshal([]byte(*topicTags), &returnValue.TopicTags) + if err != nil { + break + } + } } if err != nil { return common.BotLeetCodeTask{}, err @@ -231,6 +240,10 @@ func (y *ydbStorage) saveTask(ctx context.Context, task common.BotLeetCodeTask) if err != nil { return err } + marshalledTopicTags, err := json.Marshal(task.TopicTags) + if err != nil { + return err + } _, err = y.ydbExecuter.ProcessQuery(ctx, replaceTaskQuery, table.NewQueryParameters( table.ValueParam("$dateId", ydb.Uint64Value(task.DateID)), table.ValueParam("$questionId", ydb.Uint64Value(task.QuestionID)), @@ -239,6 +252,7 @@ func (y *ydbStorage) saveTask(ctx context.Context, task common.BotLeetCodeTask) table.ValueParam("$content", ydb.StringValue([]byte(task.Content))), table.ValueParam("$hints", ydb.StringValue(marshalledHints)), table.ValueParam("$difficulty", ydb.Uint8Value(task.GetDifficultyNum())), + table.ValueParam("$topicTags", ydb.StringValue(marshalledTopicTags)), ), ) return err diff --git a/internal/storage/ydb_test.go b/internal/storage/ydb_test.go index 61d6f04..7048712 100644 --- a/internal/storage/ydb_test.go +++ b/internal/storage/ydb_test.go @@ -103,15 +103,21 @@ func (m *YDBResultMock) Scan(values ...interface{}) error { } rowVal := reflect.ValueOf(m.rows[m.currentRow-1]) for i, fieldName := range m.respFields { - _, ok := rowVal.Type().FieldByNameFunc(func(name string) bool { + field, ok := rowVal.Type().FieldByNameFunc(func(name string) bool { return name == fieldName }) if !assert.Truef(m.t, ok, "Field %s not found in struct %s", fieldName, rowVal.Type().Name()) { return errors.New("No such field in struct") } - p := reflect.New(rowVal.FieldByName(fieldName).Type()) - p.Elem().Set(rowVal.FieldByName(fieldName)) - reflect.ValueOf(values[i]).Elem().Set(p) + fieldValue := rowVal.Field(field.Index[0]) + dest := reflect.ValueOf(values[i]).Elem() + if fieldValue.Type().AssignableTo(dest.Type()) { + dest.Set(fieldValue) + continue + } + p := reflect.New(fieldValue.Type()) + p.Elem().Set(fieldValue) + dest.Set(p) } return nil } @@ -166,6 +172,18 @@ type databaseBotLeetcodeTask struct { Content string Hints string Difficulty uint8 + TopicTags string +} + +type databaseBotLeetcodeTaskWithNullTopicTags struct { + DateID uint64 + QuestionID uint64 + TitleSlug string + Title string + Content string + Hints string + Difficulty uint8 + TopicTags *string } func (dbTask *databaseBotLeetcodeTask) fillFromBotLeetcode(task common.BotLeetCodeTask) error { @@ -179,6 +197,11 @@ func (dbTask *databaseBotLeetcodeTask) fillFromBotLeetcode(task common.BotLeetCo return err } dbTask.Hints = string(marshalledHints) + marshalledTopicTags, err := json.Marshal(task.TopicTags) + if err != nil { + return err + } + dbTask.TopicTags = string(marshalledTopicTags) dbTask.Difficulty = task.GetDifficultyNum() return nil } @@ -197,6 +220,10 @@ func TestGetTaskYDB(t *testing.T) { Content: "My test context", Hints: []string{"First hint", "Second hint", "Third hint"}, Difficulty: "Easy", + TopicTags: []leetcodeclient.TopicTag{ + {Name: "Array", Slug: "array"}, + {Name: "Simulation", Slug: "simulation"}, + }, }, } dbTask := databaseBotLeetcodeTask{} @@ -284,6 +311,94 @@ func TestGetTaskYDBBrokenJSON(t *testing.T) { assert.Equal(t, common.BotLeetCodeTask{}, resp, "Unexpected task returned") } +func TestGetTaskYDBBrokenTopicTagsJSON(t *testing.T) { + storage := newYdbStorage() + mockExecuter := new(MockQueryExecuter) + mockExecuter.t = t + dateID := uint64(55674) + taskToLoad := common.BotLeetCodeTask{ + DateID: dateID, + LeetCodeTask: leetcodeclient.LeetCodeTask{ + QuestionID: 1235, + TitleSlug: "very-very-test-slug", + Title: "Very very test task", + Content: "My test context", + Hints: []string{"First hint", "Second hint", "Third hint"}, + Difficulty: "Easy", + TopicTags: []leetcodeclient.TopicTag{{Name: "Array", Slug: "array"}}, + }, + } + dbTask := databaseBotLeetcodeTask{} + dbTask.fillFromBotLeetcode(taskToLoad) + dbTask.TopicTags = "{\"}" + mockExecuter.On( + "ProcessQuery", + trimmQuery(getTaskQuery), + table.NewQueryParameters( + table.ValueParam("$dateId", ydb.Uint64Value(dateID)), + ).String(), + ).Return( + &YDBResultMock{ + rows: []interface{}{dbTask}, + t: t, + }, + nil, + ) + storage.ydbExecuter = mockExecuter + resp, err := storage.getTask(context.Background(), dateID) + if assert.NotNil(t, err, "Expect error on broken JSON") { + assert.Equal(t, tests.ErrWrongJSON.Error(), err.Error(), "Unexpected error") + } + assert.Equal(t, common.BotLeetCodeTask{}, resp, "Unexpected task returned") +} + +func TestGetTaskYDBNullTopicTags(t *testing.T) { + storage := newYdbStorage() + mockExecuter := new(MockQueryExecuter) + mockExecuter.t = t + dateID := uint64(55674) + taskToLoad := common.BotLeetCodeTask{ + DateID: dateID, + LeetCodeTask: leetcodeclient.LeetCodeTask{ + QuestionID: 1235, + TitleSlug: "very-very-test-slug", + Title: "Very very test task", + Content: "My test context", + Hints: []string{"First hint", "Second hint", "Third hint"}, + Difficulty: "Easy", + }, + } + dbTask := databaseBotLeetcodeTask{} + dbTask.fillFromBotLeetcode(taskToLoad) + dbTaskWithNullTopicTags := databaseBotLeetcodeTaskWithNullTopicTags{ + DateID: dbTask.DateID, + QuestionID: dbTask.QuestionID, + TitleSlug: dbTask.TitleSlug, + Title: dbTask.Title, + Content: dbTask.Content, + Hints: dbTask.Hints, + Difficulty: dbTask.Difficulty, + TopicTags: nil, + } + mockExecuter.On( + "ProcessQuery", + trimmQuery(getTaskQuery), + table.NewQueryParameters( + table.ValueParam("$dateId", ydb.Uint64Value(dateID)), + ).String(), + ).Return( + &YDBResultMock{ + rows: []interface{}{dbTaskWithNullTopicTags}, + t: t, + }, + nil, + ) + storage.ydbExecuter = mockExecuter + resp, err := storage.getTask(context.Background(), dateID) + assert.Nil(t, err, "Unexpected error") + assert.Equal(t, taskToLoad, resp, "Unexpected task returned") +} + func TestGetTaskYDBScanError(t *testing.T) { storage := newYdbStorage() mockExecuter := new(MockQueryExecuter) diff --git a/pkg/leetcodeclient/client.go b/pkg/leetcodeclient/client.go index 37a75e3..da75702 100644 --- a/pkg/leetcodeclient/client.go +++ b/pkg/leetcodeclient/client.go @@ -10,12 +10,19 @@ import ( // LeetCodeTask is a necessary information about task at LeetCode type LeetCodeTask struct { - QuestionID uint64 `json:"questionId,string"` - TitleSlug string `json:"titleSlug"` - Title string `json:"questionTitle"` - Content string `json:"content"` - Hints []string `json:"hints"` - Difficulty string `json:"difficulty"` + QuestionID uint64 `json:"questionId,string"` + TitleSlug string `json:"titleSlug"` + Title string `json:"questionTitle"` + Content string `json:"content"` + Hints []string `json:"hints"` + Difficulty string `json:"difficulty"` + TopicTags []TopicTag `json:"topicTags,omitempty"` +} + +// TopicTag is a LeetCode topic tag attached to a task. +type TopicTag struct { + Name string `json:"name"` + Slug string `json:"slug"` } // LeetcodeClient represents abstract set of methods required from any possible kind of Leetcode client @@ -93,8 +100,10 @@ func (c *LeetCodeGraphQlClient) GetDailyQuestionSlug(ctx context.Context, date t func (c *LeetCodeGraphQlClient) getMonthlyQuestionsSlugs(ctx context.Context, date time.Time) ([]challengeDesc, error) { monthlyQuestionsReq := c.getDailyQuestionsSlugsReq - monthlyQuestionsReq.Variables["month"] = fmt.Sprintf("%d", int(date.Month())) - monthlyQuestionsReq.Variables["year"] = fmt.Sprintf("%d", date.Year()) + monthlyQuestionsReq.Variables = map[string]string{ + "month": fmt.Sprintf("%d", int(date.Month())), + "year": fmt.Sprintf("%d", date.Year()), + } responseBytes, err := c.transport.requestGraphQl(ctx, monthlyQuestionsReq) if err != nil { return []challengeDesc{}, err @@ -107,9 +116,9 @@ func (c *LeetCodeGraphQlClient) getMonthlyQuestionsSlugs(ctx context.Context, da // GetQuestionDetailsByTitleSlug provides all details of the question: title, text, hints, difficulty by provided titleSlug func (c *LeetCodeGraphQlClient) GetQuestionDetailsByTitleSlug(ctx context.Context, titleSlug string) (LeetCodeTask, error) { questionReq := c.getQuestionReq - questionReq.Variables["titleSlug"] = titleSlug + questionReq.Variables = map[string]string{"titleSlug": titleSlug} - responseBytes, err := c.transport.requestGraphQl(ctx, c.getQuestionReq) + responseBytes, err := c.transport.requestGraphQl(ctx, questionReq) if err != nil { return LeetCodeTask{}, err } @@ -147,7 +156,7 @@ func newLeetCodeGraphQlClient(requester graphQlRequester) *LeetCodeGraphQlClient getQuestionReq: graphQlRequest{ OperationName: "GetQuestion", Variables: make(map[string]string), - Query: "query GetQuestion($titleSlug: String!) {question(titleSlug: $titleSlug) { questionId questionTitle difficulty content hints }}", + Query: "query GetQuestion($titleSlug: String!) {question(titleSlug: $titleSlug) { questionId questionTitle difficulty content hints topicTags { name slug } }}", }, transport: requester, } diff --git a/pkg/leetcodeclient/graphqlclient_test.go b/pkg/leetcodeclient/graphqlclient_test.go index 1113def..d872ecb 100644 --- a/pkg/leetcodeclient/graphqlclient_test.go +++ b/pkg/leetcodeclient/graphqlclient_test.go @@ -168,31 +168,17 @@ func TestGetQuestionDetailsByTitleSlug(t *testing.T) { client := NewLeetCodeGraphQlClient() mockRequester := &MockRequester{} client.transport = mockRequester - questionReq := client.getQuestionReq - questionReq.Variables["titleSlug"] = "test-title0" - mockRequester.On( - "requestGraphQl", - questionReq, - ).Return( - []byte{}, - tests.ErrBypassTest, - ).Times(1) - questionReq.Variables["titleSlug"] = "test-title" - mockRequester.On( - "requestGraphQl", - questionReq, - ).Return( - []byte("{\"data\":{\"question\":{\"questionId\":\"1254\",\"questionTitle\":\"Test title\",\"difficulty\":\"Easy\",\"content\":\"

My very test content with code

\\n\\n\",\"hints\":[\"First hint\",\"Second hint\"]}}}"), - nil, - ).Times(1) - questionReq.Variables["titleSlug"] = "test-title1" - mockRequester.On( - "requestGraphQl", - questionReq, - ).Return( - []byte("{\"}"), + makeReq := func(slug string) graphQlRequest { + r := client.getQuestionReq + r.Variables = map[string]string{"titleSlug": slug} + return r + } + mockRequester.On("requestGraphQl", makeReq("test-title0")).Return([]byte{}, tests.ErrBypassTest).Times(1) + mockRequester.On("requestGraphQl", makeReq("test-title")).Return( + []byte("{\"data\":{\"question\":{\"questionId\":\"1254\",\"questionTitle\":\"Test title\",\"difficulty\":\"Easy\",\"content\":\"

My very test content with code

\\n\\n\",\"hints\":[\"First hint\",\"Second hint\"],\"topicTags\":[{\"name\":\"Array\",\"slug\":\"array\"},{\"name\":\"Simulation\",\"slug\":\"simulation\"}]}}}"), nil, ).Times(1) + mockRequester.On("requestGraphQl", makeReq("test-title1")).Return([]byte("{\""), nil).Times(1) testCases := []sliceStringLeetcodeTaskError{ {"test-title0", LeetCodeTask{}, tests.ErrBypassTest}, @@ -203,6 +189,10 @@ func TestGetQuestionDetailsByTitleSlug(t *testing.T) { Difficulty: "Easy", Content: "

My very test content with code

\n\n", Hints: []string{"First hint", "Second hint"}, + TopicTags: []TopicTag{ + {Name: "Array", Slug: "array"}, + {Name: "Simulation", Slug: "simulation"}, + }, }, nil}, {"test-title1", LeetCodeTask{}, tests.ErrWrongJSON}, } @@ -252,23 +242,16 @@ func TestGetDailyTask(t *testing.T) { []byte("{\"data\":{\"dailyCodingChallengeV2\":{\"challenges\":[{\"date\":\"1995-08-01\",\"question\":{\"titleSlug\":\"test-title\"}},{\"date\":\"1995-08-02\",\"question\":{\"titleSlug\":\"test-title2\"}},{\"date\":\"1995-08-03\",\"question\":{\"titleSlug\":\"test-title3\"}}]}}}"), nil, ).Times(2) - questionReq := client.getQuestionReq - questionReq.Variables["titleSlug"] = "test-title2" - mockRequester.On( - "requestGraphQl", - questionReq, - ).Return( - []byte("{\"data\":{\"question\":{\"questionId\":\"1254\",\"questionTitle\":\"Test title\",\"difficulty\":\"Easy\",\"content\":\"

My very test content with code

\\n\\n\",\"hints\":[\"First hint\",\"Second hint\"]}}}"), - nil, - ).Times(1) - questionReq.Variables["titleSlug"] = "test-title3" - mockRequester.On( - "requestGraphQl", - questionReq, - ).Return( - []byte("{\"}"), + makeQuestionReq := func(slug string) graphQlRequest { + r := client.getQuestionReq + r.Variables = map[string]string{"titleSlug": slug} + return r + } + mockRequester.On("requestGraphQl", makeQuestionReq("test-title2")).Return( + []byte("{\"data\":{\"question\":{\"questionId\":\"1254\",\"questionTitle\":\"Test title\",\"difficulty\":\"Easy\",\"content\":\"

My very test content with code

\\n\\n\",\"hints\":[\"First hint\",\"Second hint\"],\"topicTags\":[{\"name\":\"Array\",\"slug\":\"array\"},{\"name\":\"Simulation\",\"slug\":\"simulation\"}]}}}"), nil, ).Times(1) + mockRequester.On("requestGraphQl", makeQuestionReq("test-title3")).Return([]byte("{\""), nil).Times(1) testCases := []sliceDateLeetcodeTaskError{ {time.Date(1986, time.April, 26, 01, 23, 47, 0, loc), LeetCodeTask{}, tests.ErrBypassTest}, {time.Date(1995, time.August, 2, 01, 23, 47, 0, loc), LeetCodeTask{ @@ -278,8 +261,12 @@ func TestGetDailyTask(t *testing.T) { Difficulty: "Easy", Content: "

My very test content with code

\n\n", Hints: []string{"First hint", "Second hint"}, + TopicTags: []TopicTag{ + {Name: "Array", Slug: "array"}, + {Name: "Simulation", Slug: "simulation"}, + }, }, nil}, - {time.Date(1995, time.August, 2, 01, 23, 47, 0, loc), LeetCodeTask{}, tests.ErrWrongJSON}, + {time.Date(1995, time.August, 3, 01, 23, 47, 0, loc), LeetCodeTask{}, tests.ErrWrongJSON}, } for _, testCase := range testCases { task, err := client.GetDailyTask(context.Background(), testCase.date)