diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 137861b..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- Godoc comments on all exported types, functions, and constants. -- `ym.Ptr[T]` generic helper for optional pointer fields in request structs. -- `ym.ValidateRecipient` shared validation for ChatID/Login recipient parameters. -- Root-level and package-level `doc.go` files with usage examples. -- `CHANGELOG.md` (this file). -- `Makefile` with `test`, `lint`, `release-patch`, and `release-minor` targets. -- GitHub Actions release workflow (`.github/workflows/release.yml`). -- Architecture section in README with package layout diagram. -- Versioning section in README. - -### Changed -- Fixed incorrect import paths in README code examples (`config` -> `ymerrors`). -- Fixed aggregator references in README (`sdk.ClientSet` -> `client.YMClient`). -- Replaced `map[string]interface{}` with `map[string]any` in middleware. -- Translated `sdk.go` comments from Russian to English godoc. - -### Removed -- Duplicated `validateRecipient` functions in messages and polls packages - (replaced by shared `ym.ValidateRecipient`). diff --git a/README.en.md b/README.en.md index 1efe17b..d5ba0c1 100644 --- a/README.en.md +++ b/README.en.md @@ -12,53 +12,48 @@ go get github.com/rekurt/ymsdk ## Quick start +### Via aggregator (recommended) + ```go package main import ( "context" - "errors" "fmt" "os" + "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" - "github.com/rekurt/ymsdk/client/ym/messages" "github.com/rekurt/ymsdk/client/ym/ymerrors" ) func main() { - token := os.Getenv("YM_TOKEN") - cl := ym.NewClient(ym.Config{ - Token: token, + cs := client.New(ym.Config{ + Token: os.Getenv("YM_TOKEN"), ErrorHandling: ymerrors.ErrorHandlingConfig{ RetryStrategy: ymerrors.RetryStrategy{MaxAttempts: 3, RetryNetwork: true}, RateLimitHandling: ymerrors.RateLimitHandling{UseRetryAfter: true}, }, }) - msgSvc := messages.NewService(cl) - msg, err := msgSvc.SendToChat(context.Background(), "chat-id", "hello", nil) + msg, err := cs.Messages.SendToChat(context.Background(), "chat-id", "hello", nil) if err != nil { - handleErr(err) + fmt.Println("error:", err) return } fmt.Println("sent message:", msg.ID) } - -func handleErr(err error) { - var apiErr *ymerrors.APIError - if errors.As(err, &apiErr) { - fmt.Printf("API error kind=%d http=%d desc=%s\n", apiErr.Kind, apiErr.HTTPStatus, apiErr.Description) - if errors.Is(err, ymerrors.ErrRateLimited) && apiErr.RetryAfter > 0 { - fmt.Printf("retry after: %s\n", apiErr.RetryAfter) - } - return - } - fmt.Println("unexpected error:", err) -} ``` -See `examples/basic_send`, `examples/poller`, `examples/poll_bot`, `examples/integration`. +### Via individual services + +```go +cl := ym.NewClient(ym.Config{Token: os.Getenv("YM_TOKEN")}) +msgSvc := messages.NewService(cl) +pollSvc := polls.NewService(cl) + +msg, _ := msgSvc.SendToChat(ctx, "chat-id", "hello", nil) +``` ## Architecture @@ -71,83 +66,129 @@ client/ ├── ptr.go # ym.Ptr[T] helper for optional fields ├── validate.go # Shared recipient validation ├── ymerrors/ # Error types and configuration - ├── messages/ # Text, files, images, galleries, delete - ├── chats/ # Create chats, manage members + ├── messages/ # Text, files, images, galleries, delete, getFile + ├── chats/ # Create chats/channels, manage members ├── users/ # User chat/call deep links - ├── polls/ # Polls, results, voters - ├── updates/ # getUpdates and PollLoop + ├── polls/ # Polls: create, results, voters, GetAllVoters + ├── updates/ # getUpdates, GetUpdates, and PollLoop ├── self/ # Bot webhook_url management - └── files/ # Low-level file sending + └── files/ # Low-level file sending (byte[]) middleware/ # zap-based logging +├── logging.go # LogError, LogUpdateWithRawData, WithRequestID +├── debug.go # DebugLogger with levels (Silent → Debug) +└── http_logger.go # HTTP wrapper for request/response logging ``` ## Services -- `messages.Service` — text, files, images/galleries, delete, getFile. -- `chats.Service` — create chats/channels, update members/subscribers/admins. -- `users.Service` — fetch chat_link/call_link for a login. -- `polls.Service` — create polls, get results, list voters. -- `updates.Service` — getUpdates and `PollLoop`. -- `self.Service` — `self.update` for webhook_url. -- `middleware` — zap-based error logging helpers. -- Convenience aggregator: `client.YMClient` with prebuilt services (`client.New(cfg)`). +| Service | Description | +|---------|-------------| +| `cs.Messages` | Text messages, files, images, galleries, delete, file download | +| `cs.Chats` | Create chats/channels, add/remove members, subscribers, admins | +| `cs.Users` | Get chat_link / call_link by login | +| `cs.Polls` | Create polls, results, paginated voters, GetAllVoters | +| `cs.Updates` | getUpdates (raw + typed), PollLoop for continuous polling | +| `cs.Self` | self.update for webhook_url configuration | +| `cs.Files` | Low-level file sending via byte[] | + +Convenience aggregator `client.YMClient` with prebuilt services: +- `client.New(cfg)` — create with new HTTP client +- `client.Wrap(cl)` — wrap existing `ym.Client` ## Error handling +```go +var apiErr *ymerrors.APIError +if errors.As(err, &apiErr) { + fmt.Printf("kind=%d http=%d desc=%s request_id=%s\n", + apiErr.Kind, apiErr.HTTPStatus, apiErr.Description, apiErr.RequestID) + + if errors.Is(err, ymerrors.ErrRateLimited) && apiErr.RetryAfter > 0 { + time.Sleep(apiErr.RetryAfter) + } +} +``` + - API failures: `*ymerrors.APIError` (use `errors.As`). - Rate limit: `errors.Is(err, ymerrors.ErrRateLimited)` + `RetryAfter`. -- Auth: `ErrInvalidToken` / `ErrUnauthorized`. -- Transport: `KindNetwork` / `net.Error` when `RetryNetwork` enabled. +- Auth: `ErrInvalidToken` (403) / `ErrUnauthorized` (401). +- Transport: `KindNetwork` (5xx) / `net.Error` when `RetryNetwork` enabled. ## Configuration -`ym.Config`: - -- `BaseURL` — API endpoint (defaults to production). -- `Token` — OAuth token. -- `ErrorHandling`: - - `RetryStrategy`: `MaxAttempts`, `InitialBackoff`, `MaxBackoff`, `RetryHTTP`, `RetryNetwork`. - - `RateLimitHandling`: `UseRetryAfter`, `DefaultBackoff`. -- `UpdatesMode`: `polling` / `webhook` (explicit mode flag). - -## Examples +```go +cfg := ym.Config{ + BaseURL: "", // defaults to production endpoint + Token: os.Getenv("YM_TOKEN"), + ErrorHandling: ymerrors.ErrorHandlingConfig{ + RetryStrategy: ymerrors.RetryStrategy{ + MaxAttempts: 3, + InitialBackoff: 500 * time.Millisecond, + MaxBackoff: 10 * time.Second, + RetryNetwork: true, + RetryHTTP: []int{500, 502, 503, 504}, + }, + RateLimitHandling: ymerrors.RateLimitHandling{ + UseRetryAfter: true, + DefaultBackoff: time.Second, + }, + }, + UpdatesMode: ymerrors.UpdatesModePolling, // "polling" or "webhook" +} +``` -- `examples/basic_send` — send text to chat/login with error handling. -- `examples/poller` — polling loop respecting rate limits. -- `examples/poll_bot` — create a poll and process updates. -- `examples/integration` — end-to-end script hitting all SDK methods (configure via env vars). -- `examples/webhook` — minimal HTTP webhook receiver (webhook mode). +## Debug logging -### Quick via aggregator +Inspect raw HTTP request/response bodies with middleware: ```go -import ( - "github.com/rekurt/ymsdk/client" - "github.com/rekurt/ymsdk/client/ym" - "github.com/rekurt/ymsdk/client/ym/polls" -) +logger, _ := zap.NewDevelopmentConfig().Build() +debugLogger := middleware.NewDebugLogger(logger, middleware.LogLevelDebug) +loggedHTTP := middleware.NewHTTPLogger(&http.Client{Timeout: 15 * time.Second}, debugLogger) -cs := client.New(ym.Config{Token: "..."}) -msg, _ := cs.Messages.SendToChat(ctx, "chat-id", "hi", nil) -_ = cs.Polls.Create(ctx, &polls.CreatePollRequest{ - ChatID: ym.Ptr(ym.ChatID("chat-id")), - Title: "Q?", - Answers: []string{"A", "B"}, -}) +ymClient := ym.NewClientWithHTTP(cfg, loggedHTTP) +cs := client.Wrap(ymClient) ``` -Run integration example: +See `middleware/README.md` and `examples/debug_logger` for details. -```bash -cd examples/integration -YM_TOKEN=... YM_CHAT_ID=... YM_LOGIN=... YM_FILE_PATH=... go run . -# or: YM_TOKEN=... ./run.sh -``` +## Examples + +| Example | Description | +|---------|-------------| +| `examples/basic_send` | Send text to chat/login, reply-to, mark-important, error handling | +| `examples/poller` | Continuous polling via PollLoop, handles text/files/stickers/forwards | +| `examples/poll_bot` | Create poll, GetResults, GetAllVoters, read updates | +| `examples/webhook` | HTTP webhook receiver with secret validation, graceful shutdown, echo bot | +| `examples/debug_logger` | HTTP request/response logging, handling updates without messages | +| `examples/integration` | End-to-end script exercising all SDK methods (configure via env) | + +### Running examples -Run webhook example: ```bash +# Send a message +cd examples/basic_send +YM_TOKEN=... go run . -chat "chat-id" -text "hello" + +# Poll for updates +cd examples/poller +YM_TOKEN=... go run . + +# Poll bot +cd examples/poll_bot +YM_TOKEN=... YM_CHAT_ID=... go run . + +# Webhook server cd examples/webhook -YM_TOKEN=... YM_PORT=8080 go run . +YM_TOKEN=... YM_WEBHOOK_SECRET=... YM_PORT=8080 go run . + +# Debug logging +cd examples/debug_logger +YM_TOKEN=... go run . + +# Full integration +cd examples/integration +YM_TOKEN=... YM_CHAT_ID=... YM_LOGIN=... go run . ``` ## Versioning @@ -161,5 +202,9 @@ go get github.com/rekurt/ymsdk@v0.1.0 ## Tests ```bash +# Run all tests go test ./... + +# Lint (50+ linters) +golangci-lint run --config .golangci.yml ``` diff --git a/README.md b/README.md index 37864dd..cda8efd 100644 --- a/README.md +++ b/README.md @@ -12,53 +12,48 @@ go get github.com/rekurt/ymsdk ## Быстрый старт +### Через агрегатор (рекомендуется) + ```go package main import ( "context" - "errors" "fmt" "os" + "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" - "github.com/rekurt/ymsdk/client/ym/messages" "github.com/rekurt/ymsdk/client/ym/ymerrors" ) func main() { - token := os.Getenv("YM_TOKEN") - cl := ym.NewClient(ym.Config{ - Token: token, + cs := client.New(ym.Config{ + Token: os.Getenv("YM_TOKEN"), ErrorHandling: ymerrors.ErrorHandlingConfig{ RetryStrategy: ymerrors.RetryStrategy{MaxAttempts: 3, RetryNetwork: true}, RateLimitHandling: ymerrors.RateLimitHandling{UseRetryAfter: true}, }, }) - msgSvc := messages.NewService(cl) - msg, err := msgSvc.SendToChat(context.Background(), "chat-id", "hello", nil) + msg, err := cs.Messages.SendToChat(context.Background(), "chat-id", "hello", nil) if err != nil { - handleErr(err) + fmt.Println("error:", err) return } fmt.Println("sent message:", msg.ID) } - -func handleErr(err error) { - var apiErr *ymerrors.APIError - if errors.As(err, &apiErr) { - fmt.Printf("API error kind=%d http=%d desc=%s\n", apiErr.Kind, apiErr.HTTPStatus, apiErr.Description) - if errors.Is(err, ymerrors.ErrRateLimited) && apiErr.RetryAfter > 0 { - fmt.Printf("retry after: %s\n", apiErr.RetryAfter) - } - return - } - fmt.Println("unexpected error:", err) -} ``` -См. примеры в `examples/basic_send`, `examples/poller`, `examples/poll_bot`, `examples/integration`. +### Через отдельные сервисы + +```go +cl := ym.NewClient(ym.Config{Token: os.Getenv("YM_TOKEN")}) +msgSvc := messages.NewService(cl) +pollSvc := polls.NewService(cl) + +msg, _ := msgSvc.SendToChat(ctx, "chat-id", "hello", nil) +``` ## Архитектура @@ -71,82 +66,135 @@ client/ ├── ptr.go # Хелпер ym.Ptr[T] для optional-полей ├── validate.go # Общая валидация получателя ├── ymerrors/ # Типы ошибок и конфигурация - ├── messages/ # Текст, файлы, картинки, галереи, удаление - ├── chats/ # Создание чатов, управление участниками + ├── messages/ # Текст, файлы, картинки, галереи, удаление, getFile + ├── chats/ # Создание чатов/каналов, управление участниками ├── users/ # Ссылки на чат/звонок пользователя - ├── polls/ # Опросы, результаты, голоса - ├── updates/ # getUpdates и PollLoop + ├── polls/ # Опросы: создание, результаты, голоса + ├── updates/ # getUpdates, GetUpdates и PollLoop ├── self/ # Управление webhook_url бота - └── files/ # Низкоуровневая отправка файлов + └── files/ # Низкоуровневая отправка файлов (byte[]) middleware/ # Логирование через zap +├── logging.go # LogError, LogUpdateWithRawData, WithRequestID +├── debug.go # DebugLogger с уровнями (Silent → Debug) +└── http_logger.go # HTTP-обёртка для логирования request/response ``` ## Сервисы -- `messages.Service` — текст, файлы, картинки/галереи, delete, getFile. -- `chats.Service` — создание чатов/каналов, обновление участников/подписчиков/админов. -- `users.Service` — получение chat_link/call_link по логину. -- `polls.Service` — создание опросов, результаты, список проголосовавших. -- `updates.Service` — getUpdates и `PollLoop`. -- `self.Service` — `self.update` для webhook_url. -- `middleware` — логирование ошибок через zap. -- Для удобства есть агрегатор `client.YMClient` с уже сконструированными сервисами (`client.New(cfg)`). +| Сервис | Описание | +|--------|----------| +| `cs.Messages` | Текстовые сообщения, файлы, картинки, галереи, удаление, скачивание файлов | +| `cs.Chats` | Создание чатов/каналов, добавление/удаление участников, подписчиков, админов | +| `cs.Users` | Получение chat_link / call_link по логину | +| `cs.Polls` | Создание опросов, результаты, постраничный список голосов, GetAllVoters | +| `cs.Updates` | getUpdates (raw + typed), PollLoop для непрерывного опроса | +| `cs.Self` | self.update для настройки webhook_url | +| `cs.Files` | Низкоуровневая отправка файлов через byte[] | + +Для удобства есть агрегатор `client.YMClient` с уже сконструированными сервисами: +- `client.New(cfg)` — создание с новым HTTP-клиентом +- `client.Wrap(cl)` — обёртка над существующим `ym.Client` ## Обработка ошибок +```go +var apiErr *ymerrors.APIError +if errors.As(err, &apiErr) { + fmt.Printf("kind=%d http=%d desc=%s request_id=%s\n", + apiErr.Kind, apiErr.HTTPStatus, apiErr.Description, apiErr.RequestID) + + if errors.Is(err, ymerrors.ErrRateLimited) && apiErr.RetryAfter > 0 { + time.Sleep(apiErr.RetryAfter) + } +} +``` + - Все API-ошибки — `*ymerrors.APIError`; используйте `errors.As`. - Rate limit: `errors.Is(err, ymerrors.ErrRateLimited)` + `RetryAfter`. -- Авторизация: `ErrInvalidToken`/`ErrUnauthorized`. -- Сетевые: `KindNetwork` или `net.Error`, если включён `RetryNetwork`. +- Авторизация: `ErrInvalidToken` (403) / `ErrUnauthorized` (401). +- Сетевые: `KindNetwork` (5xx) или `net.Error`, если включён `RetryNetwork`. ## Конфигурация -`ym.Config`: - -- `BaseURL` — endpoint (по умолчанию production). -- `Token` — OAuth-токен. -- `ErrorHandling`: - - `RetryStrategy`: `MaxAttempts`, `InitialBackoff`, `MaxBackoff`, `RetryHTTP`, `RetryNetwork`. - - `RateLimitHandling`: `UseRetryAfter`, `DefaultBackoff`. -- `UpdatesMode`: `polling`/`webhook` (для явной фиксации режима). - -## Запуск примеров +```go +cfg := ym.Config{ + BaseURL: "", // по умолчанию production endpoint + Token: os.Getenv("YM_TOKEN"), + ErrorHandling: ymerrors.ErrorHandlingConfig{ + RetryStrategy: ymerrors.RetryStrategy{ + MaxAttempts: 3, // до 3 попыток + InitialBackoff: 500 * time.Millisecond, + MaxBackoff: 10 * time.Second, + RetryNetwork: true, // повторять при сетевых ошибках + RetryHTTP: []int{500, 502, 503, 504}, + }, + RateLimitHandling: ymerrors.RateLimitHandling{ + UseRetryAfter: true, // уважать Retry-After заголовок + DefaultBackoff: time.Second, + }, + }, + UpdatesMode: ymerrors.UpdatesModePolling, // "polling" или "webhook" +} +``` -- `examples/basic_send` — отправка текста в чат/логин, обработка ошибок. -- `examples/poller` — опрос обновлений с respect к rate limit. -- `examples/poll_bot` — создание опроса и чтение обновлений. -- `examples/integration` — скрипт, проходящий по всем методам SDK (настройка через env). -- `examples/webhook` — минимальный HTTP-приемник webhook (для режима webhook). +## Debug-логирование -### Быстро через агрегатор +Для отладки HTTP-запросов и ответов используйте middleware: ```go import ( - "github.com/rekurt/ymsdk/client" - "github.com/rekurt/ymsdk/client/ym" - "github.com/rekurt/ymsdk/client/ym/polls" + "github.com/rekurt/ymsdk/client" + "github.com/rekurt/ymsdk/client/ym" + "github.com/rekurt/ymsdk/middleware" ) -cs := client.New(ym.Config{Token: "..."}) -msg, _ := cs.Messages.SendToChat(ctx, "chat-id", "hi", nil) -_ = cs.Polls.Create(ctx, &polls.CreatePollRequest{ - ChatID: ym.Ptr(ym.ChatID("chat-id")), - Title: "Q?", - Answers: []string{"A", "B"}, -}) -``` +logger, _ := zap.NewDevelopmentConfig().Build() +debugLogger := middleware.NewDebugLogger(logger, middleware.LogLevelDebug) +loggedHTTP := middleware.NewHTTPLogger(&http.Client{Timeout: 15 * time.Second}, debugLogger) -Запуск интеграции: -```bash -cd examples/integration -YM_TOKEN=... YM_CHAT_ID=... YM_LOGIN=... YM_FILE_PATH=... go run . -# или: YM_TOKEN=... ./run.sh +ymClient := ym.NewClientWithHTTP(cfg, loggedHTTP) +cs := client.Wrap(ymClient) ``` -Запуск webhook-примера: +Подробнее — `middleware/README.md` и `examples/debug_logger`. + +## Примеры + +| Пример | Описание | +|--------|----------| +| `examples/basic_send` | Отправка текста в чат/логин, reply-to, mark-important, обработка ошибок | +| `examples/poller` | Непрерывный опрос обновлений через PollLoop, обработка типов (текст, файлы, стикеры, пересланные) | +| `examples/poll_bot` | Создание опроса, GetResults, GetAllVoters, чтение обновлений | +| `examples/webhook` | HTTP-приёмник webhook с валидацией секрета, graceful shutdown, echo-бот | +| `examples/debug_logger` | HTTP-логирование запросов/ответов, обработка обновлений без сообщений | +| `examples/integration` | Полный обход всех методов SDK (настройка через env) | + +### Запуск примеров + ```bash +# Отправка сообщения +cd examples/basic_send +YM_TOKEN=... go run . -chat "chat-id" -text "hello" + +# Polling обновлений +cd examples/poller +YM_TOKEN=... go run . + +# Опрос-бот +cd examples/poll_bot +YM_TOKEN=... YM_CHAT_ID=... go run . + +# Webhook-сервер cd examples/webhook -YM_TOKEN=... YM_PORT=8080 go run . +YM_TOKEN=... YM_WEBHOOK_SECRET=... YM_PORT=8080 go run . + +# Debug-логирование +cd examples/debug_logger +YM_TOKEN=... go run . + +# Полная интеграция +cd examples/integration +YM_TOKEN=... YM_CHAT_ID=... YM_LOGIN=... go run . ``` ## Версионирование @@ -160,5 +208,9 @@ go get github.com/rekurt/ymsdk@v0.1.0 ## Тесты ```bash +# Все тесты go test ./... + +# Линтинг (50+ линтеров) +golangci-lint run --config .golangci.yml ``` diff --git a/client/ym/doc.go b/client/ym/doc.go index cc67ad3..5f748f6 100644 --- a/client/ym/doc.go +++ b/client/ym/doc.go @@ -4,14 +4,27 @@ // service constructors (messages, chats, polls, etc.) or use the convenience // aggregator in the parent "client" package. // -// cl := ym.NewClient(ym.Config{ +// Using the aggregator (recommended for most use cases): +// +// cs := client.New(ym.Config{ // Token: os.Getenv("YM_TOKEN"), // ErrorHandling: ymerrors.ErrorHandlingConfig{ -// RetryStrategy: ymerrors.RetryStrategy{MaxAttempts: 3, RetryNetwork: true}, +// RetryStrategy: ymerrors.RetryStrategy{MaxAttempts: 3, RetryNetwork: true}, // RateLimitHandling: ymerrors.RateLimitHandling{UseRetryAfter: true}, // }, // }) +// msg, _ := cs.Messages.SendToChat(ctx, "chat-id", "hello", nil) +// +// Using individual services: +// +// cl := ym.NewClient(ym.Config{Token: os.Getenv("YM_TOKEN")}) // msgSvc := messages.NewService(cl) +// pollSvc := polls.NewService(cl) +// +// Shared types ([ChatID], [UserLogin], [MessageID], [Update], [Message], etc.) +// are defined in this package and used across all services. +// +// Use [Update.ToMessage] to convert an incoming update to a [Message] struct. // // Full reference: https://pkg.go.dev/github.com/rekurt/ymsdk/client/ym package ym diff --git a/doc.go b/doc.go index 76bf0e8..a9b1ae8 100644 --- a/doc.go +++ b/doc.go @@ -8,8 +8,10 @@ // // import "github.com/rekurt/ymsdk/client" // -// cs := client.New(ym.Config{Token: "..."}) +// cs := client.New(ym.Config{Token: os.Getenv("YM_TOKEN")}) // msg, _ := cs.Messages.SendToChat(ctx, "chat-id", "hello", nil) +// poll, _ := cs.Polls.Create(ctx, &polls.CreatePollRequest{...}) +// link, _ := cs.Users.GetUserLink(ctx, "john.doe") // // Or construct individual services from a core client: // @@ -17,9 +19,16 @@ // msgSvc := messages.NewService(cl) // pollSvc := polls.NewService(cl) // +// For debug logging, wrap the HTTP transport: +// +// debugLogger := middleware.NewDebugLogger(logger, middleware.LogLevelDebug) +// loggedHTTP := middleware.NewHTTPLogger(httpClient, debugLogger) +// ymClient := ym.NewClientWithHTTP(cfg, loggedHTTP) +// cs := client.Wrap(ymClient) +// // See sub-packages for detailed documentation: // - [github.com/rekurt/ymsdk/client] — YMClient aggregator // - [github.com/rekurt/ymsdk/client/ym] — core Client and shared types // - [github.com/rekurt/ymsdk/client/ym/ymerrors] — error types and configuration -// - [github.com/rekurt/ymsdk/middleware] — zap-based logging utilities +// - [github.com/rekurt/ymsdk/middleware] — zap-based logging and HTTP inspection package ymsdk diff --git a/examples/basic_send/main.go b/examples/basic_send/main.go index b904834..1700120 100644 --- a/examples/basic_send/main.go +++ b/examples/basic_send/main.go @@ -6,28 +6,53 @@ import ( "flag" "log" "os" + "os/signal" "time" "go.uber.org/zap" + "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" "github.com/rekurt/ymsdk/client/ym/messages" "github.com/rekurt/ymsdk/client/ym/ymerrors" "github.com/rekurt/ymsdk/middleware" ) +// Demonstrates sending text messages via the ymsdk aggregator. +// +// Features shown: +// - client.New aggregator for convenient multi-service access +// - Sending to chat and/or user login +// - Reply-to-message and mark-important options +// - Structured error handling with ymerrors.APIError +// - Graceful shutdown via signal context +// +// Env: YM_TOKEN (required) +// Flags: -chat, -login, -text, -reply-to, -important func main() { token := os.Getenv("YM_TOKEN") if token == "" { log.Fatal("YM_TOKEN is required") } - chatID := flag.String("chat", "", "chat id to send message") - login := flag.String("login", "", "user login to send message") - text := flag.String("text", "Hello from ymsdk", "text to send") + chatID := flag.String("chat", "", "chat ID to send message to") + login := flag.String("login", "", "user login to send message to") + text := flag.String("text", "Hello from ymsdk 👋", "message text") + replyTo := flag.String("reply-to", "", "message ID to reply to (optional)") + important := flag.Bool("important", false, "mark message as important") flag.Parse() - cfg := ym.Config{ + if *chatID == "" && *login == "" { + log.Fatal("at least one of -chat or -login is required") + } + + logger, _ := zap.NewProduction() + defer func() { _ = logger.Sync() }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + cs := client.New(ym.Config{ Token: token, ErrorHandling: ymerrors.ErrorHandlingConfig{ RetryStrategy: ymerrors.RetryStrategy{ @@ -41,30 +66,35 @@ func main() { DefaultBackoff: time.Second, }, }, - } + }) - client := ym.NewClient(cfg) - msgSvc := messages.NewService(client) - logger, _ := zap.NewProduction() - defer func() { _ = logger.Sync() }() + var opts *messages.SendMessageOptions + if *replyTo != "" || *important { + opts = &messages.SendMessageOptions{ + ReplyToMessageID: *replyTo, + MarkImportant: *important, + } + } - ctx := middleware.WithRequestID(context.Background(), "sample-req") + reqCtx := middleware.WithRequestID(ctx, "basic-send") if *chatID != "" { - if msg, err := msgSvc.SendToChat(ctx, ym.ChatID(*chatID), *text, nil); err != nil { - middleware.LogError(logger, ctx, err, "POST", "/bot/v1/messages/sendText", map[string]any{"chat_id": *chatID}) + msg, err := cs.Messages.SendToChat(reqCtx, ym.ChatID(*chatID), *text, opts) + if err != nil { + middleware.LogError(logger, reqCtx, err, "POST", "/bot/v1/messages/sendText", map[string]any{"chat_id": *chatID}) handleError(err) } else { - log.Printf("sent to chat %s message %d", *chatID, msg.ID) + log.Printf("✓ sent to chat %s — message_id=%d", *chatID, msg.ID) } } if *login != "" { - if msg, err := msgSvc.SendToLogin(ctx, ym.UserLogin(*login), *text, nil); err != nil { - middleware.LogError(logger, ctx, err, "POST", "/bot/v1/messages/sendText", map[string]any{"login": *login}) + msg, err := cs.Messages.SendToLogin(reqCtx, ym.UserLogin(*login), *text, opts) + if err != nil { + middleware.LogError(logger, reqCtx, err, "POST", "/bot/v1/messages/sendText", map[string]any{"login": *login}) handleError(err) } else { - log.Printf("sent to user %s message %d", *login, msg.ID) + log.Printf("✓ sent to user %s — message_id=%d", *login, msg.ID) } } } @@ -72,13 +102,25 @@ func main() { func handleError(err error) { var apiErr *ymerrors.APIError if errors.As(err, &apiErr) { - log.Printf("api error: kind=%d http=%d desc=%s", apiErr.Kind, apiErr.HTTPStatus, apiErr.Description) + log.Printf("✗ API error: kind=%d http=%d desc=%q", apiErr.Kind, apiErr.HTTPStatus, apiErr.Description) + if apiErr.RequestID != "" { + log.Printf(" request_id=%s", apiErr.RequestID) + } if errors.Is(err, ymerrors.ErrRateLimited) && apiErr.RetryAfter > 0 { - log.Printf(" retry after %s", apiErr.RetryAfter) + log.Printf(" rate limited — retry after %s", apiErr.RetryAfter) + } + if errors.Is(err, ymerrors.ErrInvalidToken) { + log.Printf(" hint: check that YM_TOKEN is valid and not expired") } return } - log.Printf("unexpected error: %v", err) + if errors.Is(err, context.Canceled) { + log.Println("request cancelled (interrupted)") + + return + } + + log.Printf("✗ unexpected error: %v", err) } diff --git a/examples/integration/README.en.md b/examples/integration/README.en.md index a3e57d5..cefadf1 100644 --- a/examples/integration/README.en.md +++ b/examples/integration/README.en.md @@ -2,22 +2,34 @@ [Русская версия](README.md) -This example exercises all ymsdk methods against a real bot. Configure environment variables, then run `go run .` or `run.sh`. +This example exercises all ymsdk methods via the `client.New` aggregator against a real bot. Configure environment variables, then run `go run .` or `run.sh`. ## Required - `YM_TOKEN` — bot OAuth token. ## Optional (used when present) - `YM_CHAT_ID` — chat to send messages/polls/files. -- `YM_LOGIN` — user login for direct messages/user link. +- `YM_LOGIN` — user login for direct messages and user link. - `YM_FILE_PATH` — file to send via sendFile. - `YM_IMAGE_PATH` — single image for sendImage. - `YM_GALLERY_PATHS` — comma-separated list of images for sendGallery. - `YM_FILE_ID` — file_id to fetch via getFile. - `YM_CREATE_CHAT_NAME` — create chat/channel; set `YM_CREATE_CHAT_CHANNEL=1` for channel. -- `YM_MEMBER_LOGIN` — member to add when creating chat (for chats). +- `YM_MEMBER_LOGIN` — member to add when creating chat (for chats only). - `YM_WEBHOOK_URL` — set webhook via self.update. +## Coverage + +- getUserLink — fetch chat/call deep links +- sendText — to chat and DM +- sendFile / sendImage / sendGallery — file operations +- deleteMessage — remove a message +- getFile — download a file +- createPoll / getResults / getVoters — full poll lifecycle +- createChat / updateMembers — chat creation and member management +- self.update — webhook configuration +- getUpdates — fetch updates + ## Run ```bash cd examples/integration @@ -25,5 +37,3 @@ YM_TOKEN=... YM_CHAT_ID=... YM_LOGIN=... YM_FILE_PATH=... go run . # or YM_TOKEN=... ./run.sh ``` - -The script logs each step (text, files/images/gallery, delete, getFile, polls create/results/voters, chat create/members, getUserLink, self.update, getUpdates) so you can verify end-to-end behavior quickly. diff --git a/examples/integration/README.md b/examples/integration/README.md index 19c361f..c4035ce 100644 --- a/examples/integration/README.md +++ b/examples/integration/README.md @@ -2,7 +2,7 @@ [English](README.en.md) -Этот пример последовательно вызывает все методы ymsdk против реального бота. Настройте переменные окружения и запустите `go run .` или скрипт `run.sh`. +Этот пример последовательно вызывает все методы ymsdk через агрегатор `client.New` против реального бота. Настройте переменные окружения и запустите `go run .` или скрипт `run.sh`. ## Обязательные - `YM_TOKEN` — OAuth-токен бота. @@ -18,6 +18,18 @@ - `YM_MEMBER_LOGIN` — участник, которого добавить в созданный чат (только для чатов). - `YM_WEBHOOK_URL` — установить webhook через self.update. +## Что покрывает + +- getUserLink — получение ссылок на чат/звонок +- sendText — в чат и в личку +- sendFile / sendImage / sendGallery — файловые операции +- deleteMessage — удаление сообщения +- getFile — скачивание файла +- createPoll / getResults / getVoters — полный цикл опросов +- createChat / updateMembers — создание чата и управление участниками +- self.update — настройка webhook +- getUpdates — получение обновлений + ## Запуск ```bash cd examples/integration @@ -25,5 +37,3 @@ YM_TOKEN=... YM_CHAT_ID=... YM_LOGIN=... YM_FILE_PATH=... go run . # или YM_TOKEN=... ./run.sh ``` - -Скрипт логирует шаги (текст, файлы/картинки/галерея, delete, getFile, опросы create/results/voters, создание чата и участники, getUserLink, self.update, getUpdates), чтобы быстро проверить работу всего SDK. diff --git a/examples/integration/main.go b/examples/integration/main.go index eea9597..d5e81cd 100644 --- a/examples/integration/main.go +++ b/examples/integration/main.go @@ -3,35 +3,55 @@ package main import ( "bytes" "context" + "errors" "io" "log" "os" + "os/signal" "path/filepath" "strings" "time" + "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" "github.com/rekurt/ymsdk/client/ym/chats" "github.com/rekurt/ymsdk/client/ym/messages" "github.com/rekurt/ymsdk/client/ym/polls" "github.com/rekurt/ymsdk/client/ym/self" "github.com/rekurt/ymsdk/client/ym/updates" - "github.com/rekurt/ymsdk/client/ym/users" "github.com/rekurt/ymsdk/client/ym/ymerrors" ) -// Integration exercise for all SDK methods. +// Integration exercise for all SDK methods via the client.New aggregator. +// +// Features shown: +// - All major SDK operations: messages, files, images, galleries, polls, chats, users, self, updates +// - client.New aggregator for single-point initialization +// - Structured error handling with API error details +// - Graceful shutdown via SIGINT +// // Configure via env vars before running: -// YM_TOKEN (required), YM_CHAT_ID, YM_LOGIN, YM_FILE_PATH, YM_IMAGE_PATH, -// YM_GALLERY_PATHS (comma-separated), YM_WEBHOOK_URL, YM_CREATE_CHAT_NAME, -// YM_MEMBER_LOGIN, YM_FILE_ID (for getFile). +// +// YM_TOKEN (required) OAuth bot token +// YM_CHAT_ID chat for messages/polls/files +// YM_LOGIN user login for DMs and user link +// YM_FILE_PATH local file for sendFile +// YM_IMAGE_PATH local image for sendImage +// YM_GALLERY_PATHS comma-separated paths for sendGallery +// YM_FILE_ID file_id for getFile download +// YM_WEBHOOK_URL webhook URL for self.update +// YM_CREATE_CHAT_NAME create chat (YM_CREATE_CHAT_CHANNEL=1 for channel) +// YM_MEMBER_LOGIN user to add to created chat func main() { token := os.Getenv("YM_TOKEN") if token == "" { log.Fatal("YM_TOKEN is required") } - cfg := ym.Config{ + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + cs := client.New(ym.Config{ Token: token, ErrorHandling: ymerrors.ErrorHandlingConfig{ RetryStrategy: ymerrors.RetryStrategy{ @@ -45,161 +65,228 @@ func main() { DefaultBackoff: time.Second, }, }, - } - - client := ym.NewClient(cfg) - ctx := context.Background() - - msgSvc := messages.NewService(client) - chatSvc := chats.NewService(client) - userSvc := users.NewService(client) - pollSvc := polls.NewService(client) - selfSvc := self.NewService(client) - updateSvc := updates.NewService(client) + }) chatID := ym.ChatID(os.Getenv("YM_CHAT_ID")) login := ym.UserLogin(os.Getenv("YM_LOGIN")) + // --- User Link --- if login != "" { - if link, err := userSvc.GetUserLink(ctx, login); err != nil { - log.Printf("getUserLink failed: %v", err) + section("getUserLink") + link, err := cs.Users.GetUserLink(ctx, login) + if err != nil { + logErr("getUserLink", err) } else { - log.Printf("getUserLink: %+v", link) + log.Printf(" chat_link=%s call_link=%s", link.ChatLink, link.CallLink) } } + // --- Send Text --- var lastMsgID ym.MessageID if chatID != "" { - if msg, err := msgSvc.SendToChat(ctx, chatID, "integration: hello chat", nil); err != nil { - log.Printf("send text to chat failed: %v", err) + section("sendText to chat") + msg, err := cs.Messages.SendToChat(ctx, chatID, "integration: hello from ymsdk", &messages.SendMessageOptions{ + MarkImportant: true, + }) + if err != nil { + logErr("sendText(chat)", err) } else { lastMsgID = msg.ID - log.Printf("sent text to chat %s message_id=%d", chatID, msg.ID) + log.Printf(" ✓ message_id=%d chat=%s", msg.ID, chatID) } } if login != "" { - if msg, err := msgSvc.SendToLogin(ctx, login, "integration: hello login", nil); err != nil { - log.Printf("send text to login failed: %v", err) + section("sendText to login") + msg, err := cs.Messages.SendToLogin(ctx, login, "integration: hello via DM", nil) + if err != nil { + logErr("sendText(login)", err) } else { lastMsgID = msg.ID - log.Printf("sent text to login %s message_id=%d", login, msg.ID) + log.Printf(" ✓ message_id=%d login=%s", msg.ID, login) } } + // --- Send File --- if fp := os.Getenv("YM_FILE_PATH"); fp != "" && chatID != "" { - if msg, err := sendFile(ctx, msgSvc, chatID, fp); err != nil { - log.Printf("sendFile failed: %v", err) + section("sendFile") + msg, err := sendFile(ctx, cs.Messages, chatID, fp) + if err != nil { + logErr("sendFile", err) } else { lastMsgID = msg.ID - log.Printf("sendFile ok message_id=%d", msg.ID) + log.Printf(" ✓ message_id=%d file=%s", msg.ID, filepath.Base(fp)) } } + // --- Send Image --- if ip := os.Getenv("YM_IMAGE_PATH"); ip != "" && chatID != "" { - if msg, err := sendImage(ctx, msgSvc, chatID, ip); err != nil { - log.Printf("sendImage failed: %v", err) + section("sendImage") + msg, err := sendImage(ctx, cs.Messages, chatID, ip) + if err != nil { + logErr("sendImage", err) } else { lastMsgID = msg.ID - log.Printf("sendImage ok message_id=%d", msg.ID) + log.Printf(" ✓ message_id=%d image=%s", msg.ID, filepath.Base(ip)) } } + // --- Send Gallery --- if gp := os.Getenv("YM_GALLERY_PATHS"); gp != "" && chatID != "" { - if msg, err := sendGallery(ctx, msgSvc, chatID, gp); err != nil { - log.Printf("sendGallery failed: %v", err) + section("sendGallery") + msg, err := sendGallery(ctx, cs.Messages, chatID, gp) + if err != nil { + logErr("sendGallery", err) } else { lastMsgID = msg.ID - log.Printf("sendGallery ok message_id=%d", msg.ID) + log.Printf(" ✓ message_id=%d images=%d", msg.ID, len(strings.Split(gp, ","))) } } + // --- Delete Message --- if lastMsgID != 0 && chatID != "" { - if err := msgSvc.Delete(ctx, &messages.DeleteMessageRequest{ChatID: &chatID, MessageID: lastMsgID}); err != nil { - log.Printf("delete message failed: %v", err) + section("deleteMessage") + if err := cs.Messages.Delete(ctx, &messages.DeleteMessageRequest{ChatID: &chatID, MessageID: lastMsgID}); err != nil { + logErr("delete", err) } else { - log.Printf("delete message %d ok", lastMsgID) + log.Printf(" ✓ deleted message_id=%d", lastMsgID) } } + // --- Get File --- if fid := os.Getenv("YM_FILE_ID"); fid != "" { - rc, meta, err := msgSvc.GetFile(ctx, fid) + section("getFile") + rc, meta, err := cs.Messages.GetFile(ctx, fid) if err != nil { - log.Printf("getFile failed: %v", err) + logErr("getFile", err) } else { - defer rc.Close() - _, _ = io.Copy(io.Discard, rc) - log.Printf("getFile ok id=%s content_type=%s length=%d", meta.FileID, meta.ContentType, meta.ContentLength) + n, _ := io.Copy(io.Discard, rc) + _ = rc.Close() + log.Printf(" ✓ file_id=%s content_type=%s declared=%d read=%d", + meta.FileID, meta.ContentType, meta.ContentLength, n) } } + // --- Polls --- if chatID != "" { - msg, err := pollSvc.Create(ctx, &polls.CreatePollRequest{ - ChatID: &chatID, - Title: "integration poll", - Answers: []string{"Yes", "No"}, + section("createPoll") + msg, err := cs.Polls.Create(ctx, &polls.CreatePollRequest{ + ChatID: &chatID, + Title: "Integration poll: pick one", + Answers: []string{"Option A", "Option B", "Option C"}, + IsAnonymous: ym.Ptr(false), }) if err != nil { - log.Printf("create poll failed: %v", err) + logErr("createPoll", err) } else { - log.Printf("poll created message_id=%d", msg.ID) - if res, err := pollSvc.GetResults(ctx, polls.PollResultsParams{ChatID: &chatID, MessageID: msg.ID}); err != nil { - log.Printf("getResults failed: %v", err) + log.Printf(" ✓ poll message_id=%d", msg.ID) + + section("getResults") + if res, resErr := cs.Polls.GetResults(ctx, polls.PollResultsParams{ChatID: &chatID, MessageID: msg.ID}); resErr != nil { + logErr("getResults", resErr) } else { - log.Printf("getResults ok voted=%d answers=%v", res.VotedCount, res.Answers) + log.Printf(" ✓ voted=%d answers=%v", res.VotedCount, res.Answers) } - if voters, err := pollSvc.GetVotersPage(ctx, polls.PollVotersParams{ChatID: &chatID, MessageID: msg.ID, AnswerID: 1, Limit: intPtr(10)}); err != nil { - log.Printf("getVoters failed: %v", err) + + section("getVoters (answer #1)") + if voters, votersErr := cs.Polls.GetVotersPage(ctx, polls.PollVotersParams{ + ChatID: &chatID, + MessageID: msg.ID, + AnswerID: 1, + Limit: ym.Ptr(10), + }); votersErr != nil { + logErr("getVoters", votersErr) } else { - log.Printf("getVoters ok count=%d cursor=%d", voters.VotedCount, voters.Cursor) + log.Printf(" ✓ answer_1_voted=%d cursor=%d", voters.VotedCount, voters.Cursor) } } } + // --- Create Chat/Channel --- if name := os.Getenv("YM_CREATE_CHAT_NAME"); name != "" { - channel := strings.ToLower(os.Getenv("YM_CREATE_CHAT_CHANNEL")) == "1" - req := &chats.ChatCreateRequest{ - Name: name, - Description: "integration chat", - Channel: channel, + isChannel := strings.EqualFold(os.Getenv("YM_CREATE_CHAT_CHANNEL"), "1") + kind := "chat" + if isChannel { + kind = "channel" } - if chat, err := chatSvc.Create(ctx, req); err != nil { - log.Printf("create chat failed: %v", err) + section("createChat (" + kind + ")") + + chat, err := cs.Chats.Create(ctx, &chats.ChatCreateRequest{ + Name: name, + Description: "integration " + kind, + Channel: isChannel, + }) + if err != nil { + logErr("createChat", err) } else { - log.Printf("create chat ok id=%s", chat.ID) - if ml := os.Getenv("YM_MEMBER_LOGIN"); ml != "" && !channel { - err := chatSvc.UpdateMembers(ctx, &chats.ChatUpdateMembersRequest{ + log.Printf(" ✓ %s id=%s", kind, chat.ID) + + if ml := os.Getenv("YM_MEMBER_LOGIN"); ml != "" && !isChannel { + section("updateMembers") + if memberErr := cs.Chats.UpdateMembers(ctx, &chats.ChatUpdateMembersRequest{ ChatID: chat.ID, Members: []ym.UserRef{{Login: ym.UserLogin(ml)}}, - }) - if err != nil { - log.Printf("updateMembers failed: %v", err) + }); memberErr != nil { + logErr("updateMembers", memberErr) } else { - log.Printf("updateMembers ok") + log.Printf(" ✓ added %s to %s", ml, chat.ID) } } } } + // --- Webhook Setup --- if wh := os.Getenv("YM_WEBHOOK_URL"); wh != "" { - if selfObj, err := selfSvc.Update(ctx, &self.SelfUpdateRequest{WebhookURL: &wh}); err != nil { - log.Printf("self.update webhook failed: %v", err) + section("self.update (webhook)") + bot, err := cs.Self.Update(ctx, &self.SelfUpdateRequest{WebhookURL: &wh}) + if err != nil { + logErr("self.update", err) } else { - log.Printf("self.update webhook ok: %+v", selfObj) + log.Printf(" ✓ bot=%s display=%s webhook=%v", bot.ID, bot.DisplayName, bot.WebhookURL) } } - limit := 10 - upds, next, err := updateSvc.GetUpdates(ctx, updates.GetUpdatesParams{Limit: &limit}) + // --- Get Updates --- + section("getUpdates") + upds, next, err := cs.Updates.GetUpdates(ctx, updates.GetUpdatesParams{Limit: ym.Ptr(10)}) if err != nil { - log.Printf("getUpdates failed: %v", err) + logErr("getUpdates", err) } else { - log.Printf("getUpdates ok updates=%d next_offset=%d", len(upds), next) + log.Printf(" ✓ updates=%d next_offset=%d", len(upds), next) for _, u := range upds { - if u.MessageID > 0 && u.Chat != nil { - log.Printf("update %d chat=%s text=%s", u.UpdateID, u.Chat.ID, u.Text) + if u.Chat != nil { + log.Printf(" update %d: chat=%s text=%q", u.UpdateID, u.Chat.ID, truncate(u.Text, 80)) } } } + + log.Println("\n=== integration complete ===") +} + +func section(name string) { + log.Printf("\n--- %s ---", name) +} + +func logErr(op string, err error) { + var apiErr *ymerrors.APIError + if errors.As(err, &apiErr) { + log.Printf(" ✗ %s: API error kind=%d http=%d desc=%q request_id=%s", + op, apiErr.Kind, apiErr.HTTPStatus, apiErr.Description, apiErr.RequestID) + if apiErr.RetryAfter > 0 { + log.Printf(" retry_after=%s", apiErr.RetryAfter) + } + + return + } + + log.Printf(" ✗ %s: %v", op, err) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + + return s[:maxLen] + "..." } func sendFile(ctx context.Context, svc *messages.Service, chatID ym.ChatID, path string) (*ym.Message, error) { @@ -250,5 +337,3 @@ func sendGallery(ctx context.Context, svc *messages.Service, chatID ym.ChatID, p Images: parts, }) } - -func intPtr(v int) *int { return &v } diff --git a/examples/poll_bot/main.go b/examples/poll_bot/main.go index 4db13fb..8a466af 100644 --- a/examples/poll_bot/main.go +++ b/examples/poll_bot/main.go @@ -4,57 +4,138 @@ import ( "context" "log" "os" + "os/signal" "time" + "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" "github.com/rekurt/ymsdk/client/ym/polls" "github.com/rekurt/ymsdk/client/ym/updates" "github.com/rekurt/ymsdk/client/ym/ymerrors" ) +// Demonstrates poll creation, result fetching, and voter listing. +// +// Features shown: +// - Creating a poll with multiple answers and options +// - Fetching aggregated poll results via GetResults +// - Paginating individual voters via GetVotersPage +// - Collecting all voters via GetAllVoters helper +// - Polling for new updates after poll creation +// - Graceful shutdown via SIGINT +// +// Env: YM_TOKEN (required), YM_CHAT_ID (required) func main() { token := os.Getenv("YM_TOKEN") chat := os.Getenv("YM_CHAT_ID") if token == "" || chat == "" { - log.Fatal("YM_TOKEN and YM_CHAT_ID required") + log.Fatal("YM_TOKEN and YM_CHAT_ID are required") } - cfg := ym.Config{ + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + cs := client.New(ym.Config{ Token: token, ErrorHandling: ymerrors.ErrorHandlingConfig{ - RetryStrategy: ymerrors.RetryStrategy{MaxAttempts: 3, RetryNetwork: true, InitialBackoff: 500 * time.Millisecond, MaxBackoff: 3 * time.Second}, - RateLimitHandling: ymerrors.RateLimitHandling{UseRetryAfter: true, DefaultBackoff: time.Second}, + RetryStrategy: ymerrors.RetryStrategy{ + MaxAttempts: 3, + InitialBackoff: 500 * time.Millisecond, + MaxBackoff: 5 * time.Second, + RetryNetwork: true, + }, + RateLimitHandling: ymerrors.RateLimitHandling{ + UseRetryAfter: true, + DefaultBackoff: time.Second, + }, }, - } - client := ym.NewClient(cfg) - pollSvc := polls.NewService(client) - updateSvc := updates.NewService(client) - - ctx := context.Background() - msg, err := pollSvc.Create(ctx, &polls.CreatePollRequest{ - ChatID: ym.Ptr(ym.ChatID(chat)), - Title: "Tea or coffee?", - Answers: []string{"Tea", "Coffee"}, + }) + + chatID := ym.ChatID(chat) + + // --- Create poll --- + log.Println("creating poll...") + msg, err := cs.Polls.Create(ctx, &polls.CreatePollRequest{ + ChatID: &chatID, + Title: "What's your favourite language?", + Answers: []string{"Go", "Rust", "Python", "TypeScript"}, + MaxChoices: ym.Ptr(2), + IsAnonymous: ym.Ptr(false), }) if err != nil { log.Fatalf("create poll failed: %v", err) } - log.Printf("Poll sent with message id %d", msg.ID) + log.Printf("✓ poll created — message_id=%d", msg.ID) + + // --- Fetch results --- + log.Println("fetching poll results...") + results, err := cs.Polls.GetResults(ctx, polls.PollResultsParams{ + ChatID: &chatID, + MessageID: msg.ID, + }) + if err != nil { + log.Printf("✗ getResults failed: %v", err) + } else { + log.Printf("✓ poll results: total_voted=%d", results.VotedCount) + answers := []string{"Go", "Rust", "Python", "TypeScript"} + for id, count := range results.Answers { + name := "unknown" + if id > 0 && id <= len(answers) { + name = answers[id-1] + } + log.Printf(" %s: %d votes", name, count) + } + } - // simple polling for results - offset := int64(0) - limit := 20 + // --- Fetch voters for first answer --- + log.Println("fetching voters for answer #1...") + voters, err := cs.Polls.GetAllVoters(ctx, polls.PollVotersParams{ + ChatID: &chatID, + MessageID: msg.ID, + AnswerID: 1, + Limit: ym.Ptr(50), + }) + if err != nil { + log.Printf("✗ getAllVoters failed: %v", err) + } else { + log.Printf("✓ answer #1 voters: %d total", len(voters)) + for _, v := range voters { + log.Printf(" - %s (ts=%d)", v.User.Login, v.Timestamp) + } + } + + // --- Poll for updates --- + log.Println("polling for updates (3 rounds)...") + var offset int64 for range 3 { - upds, next, err := updateSvc.GetUpdates(ctx, updates.GetUpdatesParams{Limit: &limit, Offset: &offset}) - if err != nil { - log.Fatalf("get updates: %v", err) + select { + case <-ctx.Done(): + log.Println("interrupted") + + return + default: } + + limit := 20 + upds, next, updateErr := cs.Updates.GetUpdates(ctx, updates.GetUpdatesParams{ + Limit: &limit, + Offset: &offset, + }) + if updateErr != nil { + log.Printf("✗ getUpdates: %v", updateErr) + + break + } + for _, u := range upds { - if u.MessageID > 0 { - log.Printf("update %d text=%s", u.UpdateID, u.Text) + if u.MessageID > 0 && u.Chat != nil { + log.Printf(" update %d: chat=%s text=%q", u.UpdateID, u.Chat.ID, u.Text) } } + offset = next - time.Sleep(time.Second) + time.Sleep(2 * time.Second) } + + log.Println("done") } diff --git a/examples/poller/main.go b/examples/poller/main.go index a800366..a3f59d5 100644 --- a/examples/poller/main.go +++ b/examples/poller/main.go @@ -5,85 +5,112 @@ import ( "errors" "log" "os" + "os/signal" "time" "go.uber.org/zap" + "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" "github.com/rekurt/ymsdk/client/ym/updates" "github.com/rekurt/ymsdk/client/ym/ymerrors" "github.com/rekurt/ymsdk/middleware" ) +// Demonstrates long-polling for updates with the ymsdk PollLoop. +// +// Features shown: +// - client.New aggregator with retry/rate-limit configuration +// - PollLoop for continuous update retrieval +// - Handling different update types (text, image, file, sticker, forward, gallery) +// - Graceful shutdown via SIGINT +// - Structured error logging with middleware +// +// Env: YM_TOKEN (required) func main() { token := os.Getenv("YM_TOKEN") if token == "" { log.Fatal("YM_TOKEN is required") } - cfg := ym.Config{ + logger, _ := zap.NewProduction() + defer func() { _ = logger.Sync() }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + cs := client.New(ym.Config{ Token: token, ErrorHandling: ymerrors.ErrorHandlingConfig{ RetryStrategy: ymerrors.RetryStrategy{ - MaxAttempts: 3, + MaxAttempts: 5, InitialBackoff: 500 * time.Millisecond, - MaxBackoff: 5 * time.Second, + MaxBackoff: 10 * time.Second, RetryNetwork: true, }, RateLimitHandling: ymerrors.RateLimitHandling{ UseRetryAfter: true, - DefaultBackoff: time.Second, + DefaultBackoff: 2 * time.Second, }, }, - } + }) - client := ym.NewClient(cfg) - updateSvc := updates.NewService(client) - logger, _ := zap.NewProduction() - defer func() { - _ = logger.Sync() - }() - - ctx := middleware.WithRequestID(context.Background(), "poller") - offset := "" - - for { - upds, nextOffset, err := updateSvc.Get(ctx, 20, offset) - if err != nil { - if handleAPIError(err) { - middleware.LogError(logger, ctx, err, "GET", "/bot/v1/messages/getUpdates", map[string]any{"offset": offset}) - - continue - } - log.Fatalf("get updates failed: %v", err) - } + log.Println("polling for updates... (Ctrl+C to stop)") - for _, u := range upds { - if u.MessageID > 0 && u.Chat != nil && u.From != nil { - log.Printf("[%s] %s: %s", u.Chat.ID, u.From.Login, u.Text) - } - } + err := cs.Updates.PollLoop(ctx, updates.GetUpdatesParams{Limit: ym.Ptr(20)}, + func(ctx context.Context, u ym.Update) error { + logUpdate(logger, u) - offset = nextOffset - time.Sleep(time.Second) + return nil + }, + ) + if err != nil && !errors.Is(err, context.Canceled) { + middleware.LogError(logger, ctx, err, "GET", "/bot/v1/messages/getUpdates", nil) + log.Fatalf("poll loop failed: %v", err) } + + log.Println("shutdown complete") } -func handleAPIError(err error) bool { - var apiErr *ymerrors.APIError - if errors.As(err, &apiErr) { - log.Printf("api error kind=%d http=%d desc=%s", apiErr.Kind, apiErr.HTTPStatus, apiErr.Description) - if errors.Is(err, ymerrors.ErrRateLimited) && apiErr.RetryAfter > 0 { - time.Sleep(apiErr.RetryAfter) +func logUpdate(logger *zap.Logger, u ym.Update) { + if u.Chat == nil || u.From == nil { + logger.Warn("update without chat/sender info", + zap.Int64("update_id", u.UpdateID), + ) - return true + return + } + + chatID := string(u.Chat.ID) + sender := string(u.From.Login) + + switch { + case u.Forward != nil: + fwdFrom := "unknown" + if u.Forward.From != nil { + fwdFrom = string(u.Forward.From.Login) } - if errors.Is(err, ymerrors.ErrInvalidToken) || errors.Is(err, ymerrors.ErrUnauthorized) { - return false + log.Printf("[%s] %s forwarded from %s: %s", chatID, sender, fwdFrom, u.Text) + + case u.Sticker != nil: + log.Printf("[%s] %s sent sticker: %s (id=%s)", chatID, sender, u.Sticker.Emoji, u.Sticker.ID) + + case len(u.Gallery) > 0: + log.Printf("[%s] %s sent gallery with %d images", chatID, sender, len(u.Gallery)) + + case u.Image != nil: + log.Printf("[%s] %s sent image: %dx%d (id=%s)", chatID, sender, u.Image.Width, u.Image.Height, u.Image.ID) + + case u.Document != nil: + log.Printf("[%s] %s sent file: %s (%s, %d bytes)", chatID, sender, u.Document.Name, u.Document.MimeType, u.Document.Size) + + case u.Text != "": + log.Printf("[%s] %s: %s", chatID, sender, u.Text) + if u.ThreadID != nil { + log.Printf(" └─ in thread %d", *u.ThreadID) } - return true + default: + log.Printf("[%s] %s: (empty update, possibly edit/delete event)", chatID, sender) } - - return false } diff --git a/examples/webhook/main.go b/examples/webhook/main.go index c5551bd..f08a928 100644 --- a/examples/webhook/main.go +++ b/examples/webhook/main.go @@ -1,12 +1,16 @@ package main import ( + "context" + "crypto/subtle" "encoding/json" "fmt" "io" "log" "net/http" "os" + "os/signal" + "time" "github.com/rekurt/ymsdk/client" "github.com/rekurt/ymsdk/client/ym" @@ -14,61 +18,175 @@ import ( "github.com/rekurt/ymsdk/client/ym/ymerrors" ) -// Minimal webhook receiver: starts HTTP server, parses updates from YM and replies via SendToChat. +// Webhook receiver that parses incoming updates and replies with an echo. +// +// Features shown: +// - Webhook secret validation with constant-time comparison +// - Request body size limiting (1 MB) +// - Handling different update types (text, image, file, sticker) +// - Reply-to-message for threaded responses +// - Graceful HTTP server shutdown on SIGINT +// // Env: -// YM_TOKEN (required), YM_REPLY_CHAT (optional default from incoming update), -// YM_PORT (default 8080). +// +// YM_TOKEN (required) OAuth bot token +// YM_WEBHOOK_SECRET (required) shared secret for X-Webhook-Secret header +// YM_REPLY_CHAT (optional) override reply chat ID; defaults to incoming chat +// YM_PORT (optional) HTTP listen port; defaults to 8080 func main() { - token := os.Getenv("YM_TOKEN") - if token == "" { - log.Fatal("YM_TOKEN is required") - } - port := os.Getenv("YM_PORT") - if port == "" { - port = "8080" - } + token := mustEnv("YM_TOKEN") + webhookSecret := mustEnv("YM_WEBHOOK_SECRET") + port := envOrDefault("YM_PORT", "8080") - s := client.New(ym.Config{ + cs := client.New(ym.Config{ Token: token, UpdatesMode: ymerrors.UpdatesModeWebhook, + ErrorHandling: ymerrors.ErrorHandlingConfig{ + RetryStrategy: ymerrors.RetryStrategy{ + MaxAttempts: 2, + InitialBackoff: 300 * time.Millisecond, + MaxBackoff: 2 * time.Second, + }, + RateLimitHandling: ymerrors.RateLimitHandling{ + UseRetryAfter: true, + DefaultBackoff: time.Second, + }, + }, }) - http.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) { + mux := http.NewServeMux() + mux.HandleFunc("/webhook", webhookHandler(cs, webhookSecret)) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "ok") + }) + + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + go func() { + <-ctx.Done() + log.Println("shutting down...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if shutdownErr := srv.Shutdown(shutdownCtx); shutdownErr != nil { + log.Printf("shutdown error: %v", shutdownErr) + } + }() + + log.Printf("listening on :%s (endpoints: /webhook, /health)", port) + if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed { + log.Fatalf("server error: %v", listenErr) + } + + log.Println("shutdown complete") +} + +func webhookHandler(cs *client.YMClient, secret string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() - body, _ := io.ReadAll(r.Body) - var upd ym.Update - if err := json.Unmarshal(body, &upd); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - log.Printf("got update %d", upd.UpdateID) - if upd.MessageID > 0 && upd.Chat != nil && upd.From != nil { - replyChat := os.Getenv("YM_REPLY_CHAT") - target := upd.Chat.ID - if replyChat != "" { - target = ym.ChatID(replyChat) - } - - _, err := s.Messages.SendToChat(r.Context(), target, "echo: "+upd.Text, &messages.SendMessageOptions{ - ReplyToMessageID: fmt.Sprintf("%d", upd.MessageID), - }) - if err != nil { - log.Printf("send reply failed: %v", err) - } + if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Webhook-Secret")), []byte(secret)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + + return } - w.WriteHeader(http.StatusOK) - _, err := w.Write([]byte(`{"ok":true}`)) - if err != nil { - log.Printf("write failed: %v", err) + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + body, readErr := io.ReadAll(r.Body) + if readErr != nil { + http.Error(w, "bad request", http.StatusBadRequest) return } - }) - log.Printf("listening on :%s", port) - log.Fatal(http.ListenAndServe(":"+port, nil)) + var upd ym.Update + if unmarshalErr := json.Unmarshal(body, &upd); unmarshalErr != nil { + http.Error(w, "bad request", http.StatusBadRequest) + + return + } + + log.Printf("webhook update %d", upd.UpdateID) + processUpdate(r.Context(), cs, upd) + + w.WriteHeader(http.StatusOK) + if _, writeErr := w.Write([]byte(`{"ok":true}`)); writeErr != nil { + log.Printf("write response failed: %v", writeErr) + } + } +} + +func processUpdate(ctx context.Context, cs *client.YMClient, upd ym.Update) { + if upd.Chat == nil || upd.From == nil || upd.MessageID == 0 { + log.Printf(" skipping: no chat/sender/message info") + + return + } + + target := upd.Chat.ID + if override := os.Getenv("YM_REPLY_CHAT"); override != "" { + target = ym.ChatID(override) + } + + var replyText string + switch { + case upd.Sticker != nil: + replyText = fmt.Sprintf("Nice sticker! %s", upd.Sticker.Emoji) + case upd.Image != nil: + replyText = fmt.Sprintf("Got your image (%dx%d)", upd.Image.Width, upd.Image.Height) + case len(upd.Gallery) > 0: + replyText = fmt.Sprintf("Got %d images in gallery", len(upd.Gallery)) + case upd.Document != nil: + replyText = fmt.Sprintf("Got file: %s (%d bytes)", upd.Document.Name, upd.Document.Size) + case upd.Forward != nil: + replyText = "Got a forwarded message" + case upd.Text != "": + replyText = "echo: " + upd.Text + default: + log.Printf(" skipping: unsupported update type") + + return + } + + opts := &messages.SendMessageOptions{ + ReplyToMessageID: fmt.Sprintf("%d", upd.MessageID), + } + + if _, sendErr := cs.Messages.SendToChat(ctx, target, replyText, opts); sendErr != nil { + log.Printf(" send reply failed: %v", sendErr) + } else { + log.Printf(" replied to %s in %s", upd.From.Login, upd.Chat.ID) + } +} + +func mustEnv(key string) string { + v := os.Getenv(key) + if v == "" { + log.Fatalf("%s is required", key) + } + + return v +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + + return fallback } diff --git a/middleware/README.md b/middleware/README.md index 9749a79..5db6fbe 100644 --- a/middleware/README.md +++ b/middleware/README.md @@ -5,8 +5,8 @@ This package provides logging and HTTP instrumentation utilities for the ymsdk. ## Overview The middleware package includes: -- **Error logging** with structured Zap integration -- **Debug logging** with configurable log levels +- **Error logging** with structured Zap integration and request correlation +- **Debug logging** with configurable log levels (Silent → Debug) - **HTTP logging wrapper** for inspecting raw request/response bodies - **Update logging helpers** for debugging message parsing issues @@ -18,15 +18,15 @@ Use `LogError` to log API and client errors with full context: import "github.com/rekurt/ymsdk/middleware" logger, _ := zap.NewProduction() +ctx := middleware.WithRequestID(context.Background(), "req-123") -// Log API errors err := client.DoRequest(ctx, "GET", "/endpoint", nil) if err != nil { - middleware.LogError(logger, ctx, err, "GET", "/endpoint", params) + middleware.LogError(logger, ctx, err, "GET", "/endpoint", map[string]any{"offset": offset}) } ``` -This logs the error kind, HTTP status, description, retry information, and request metadata. +This logs the error kind, HTTP status, description, retry information, request ID, and request metadata. ## Debug Logging @@ -41,9 +41,8 @@ logger, _ := zap.NewProduction() debugLogger := middleware.NewDebugLogger(logger, middleware.LogLevelDebug) // Logs are only written if level matches -debugLogger.LogDebug(ctx, "processing update") // if level >= LogLevelDebug +debugLogger.LogDebug(ctx, "processing update") // if level >= LogLevelDebug debugLogger.LogWarning(ctx, "no message in update") // if level >= LogLevelWarn -debugLogger.LogInfo(ctx, "message received") // if level >= LogLevelInfo ``` ### Log Levels @@ -70,6 +69,9 @@ loggedClient := middleware.NewHTTPLogger(baseClient, debugLogger) // Use with ymsdk ymClient := ym.NewClientWithHTTP(cfg, loggedClient) + +// Or use with the aggregator +cs := client.Wrap(ymClient) ``` Output at DEBUG level: @@ -84,19 +86,24 @@ DEBUG HTTP Response body: {"ok":true,"result":{"id":12345,...}} ``` -## Handling Updates Without Message +Note: Authorization headers are automatically excluded from logs. + +## Handling Updates Without Message Data -The SDK's `Update` struct has `Message` as an optional field. Not all updates include message data: +The SDK's `Update` struct has optional `Chat`, `From`, and `MessageID` fields. +Not all updates include full message data (e.g., edit/delete events): ```go err := cs.Updates.PollLoop(ctx, params, func(ctx context.Context, update ym.Update) error { - if update.Message == nil { - // Log this for debugging + if update.Chat == nil || update.From == nil || update.MessageID == 0 { + // Not a complete message update — log for debugging middleware.LogUpdateWithRawData(logger, ctx, update, rawJSON) return nil } - // Process message + // Process complete message via ToMessage() + msg := update.ToMessage() + // ... handle msg return nil }) ``` @@ -124,7 +131,16 @@ middleware.LogUpdateWithRawData(logger, ctx, update, rawUpdateJSON) middleware.LogUnparsedUpdate(logger, ctx, []byte(`{bad json}`)) ``` -### 3. Conditional Logging +### 3. Request Correlation + +Use `WithRequestID` to add correlation IDs that appear in all log entries: + +```go +ctx := middleware.WithRequestID(context.Background(), "handler-123") +// All LogError calls with this ctx will include request_id=handler-123 +``` + +### 4. Conditional Logging Use log levels to reduce noise: @@ -141,3 +157,4 @@ debugLogger := middleware.NewDebugLogger(logger, middleware.LogLevelWarn) - Production: `LogLevelWarn` or `LogLevelError` - Development: `LogLevelDebug` 4. **Check nil logger**: All logging functions check for nil logger first +5. **Use request IDs**: Add `WithRequestID` to contexts for log correlation