REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

..
Author SHA1 Message Date
ScuroNeko 5d3199dc21 fix(tgapi): accept string chat boost IDs 2026-03-25 18:20:20 +03:00
ScuroNeko 158625c220 fix bot lifecycle and docs 2026-03-25 18:07:41 +03:00
ScuroNeko 7901fb659e fix bot safety and tgapi edge cases 2026-03-25 13:41:30 +03:00
ScuroNeko eda635e72c fix: enforce required command arg positions 2026-03-25 13:17:44 +03:00
ScuroNeko f0da64c7af logger now initialize before middleware 2026-03-25 12:57:11 +03:00
ScuroNeko 3861746a3e logger now initialize before middleware 2026-03-25 12:56:26 +03:00
ScuroNeko 401173714e refactor(logging): use context logger in MsgContext 2026-03-24 14:02:51 +03:00
ScuroNeko 7776acaf12 refactor logging setup and split local/remote close APIs 2026-03-24 13:45:19 +03:00
ScuroNeko db31246eeb retract 1.0.0 rc 5 2026-03-23 13:19:39 +03:00
ScuroNeko d04c91342b small close fix; logo in readme 2026-03-23 13:13:47 +03:00
ScuroNeko 2e14d8b5df small close fix; logo in readme 2026-03-23 12:59:18 +03:00
ScuroNeko 6b9075c722 feat(tgapi): add context-aware API/uploader methods and align params/docs with Telegram Bot API 2026-03-19 15:10:49 +03:00
ScuroNeko 0b1a58a514 readme change 2026-03-19 14:23:23 +03:00
ScuroNeko c59dd1fe8e Handle canceled update polling without shutdown delay 2026-03-19 14:16:31 +03:00
ScuroNeko 2fc171d9a3 fix shutdown long polling cancellation and database logger close 2026-03-19 14:07:03 +03:00
ScuroNeko 4ebe76dd4a fix: correct Telegram update/keyboard models and harden env parsing 2026-03-17 16:17:26 +03:00
75 changed files with 5056 additions and 723 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
.idea/
test/
test/
+14
View File
@@ -0,0 +1,14 @@
version: "2"
run:
timeout: 5m
linters:
disable-all: true
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
issues:
max-issues-per-linter: 0
max-same-issues: 0
+41
View File
@@ -0,0 +1,41 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: local
hooks:
- id: gofmt
name: gofmt
entry: gofmt -w
language: system
types: [go]
- id: go-vet
name: go vet
entry: go vet ./...
language: system
pass_filenames: false
types: [go]
- id: golangci-lint
name: golangci-lint
entry: golangci-lint run
language: system
pass_filenames: false
types: [go]
- id: go-test
name: go test
entry: go test ./...
language: system
pass_filenames: false
stages: [pre-push]
types: [go]
+102
View File
@@ -0,0 +1,102 @@
# AGENTS.md
## Purpose
This repository uses Codex for full-project Go code review, not diff-only review.
When asked to review code, inspect the entire repository and use repository-wide context. Do not limit analysis to the latest commit, pull request diff, or recently changed files.
## Review priorities
Review the codebase with focus on:
- correctness and reliability;
- maintainability and architecture;
- idiomatic Go;
- testability;
- performance where justified by code evidence;
- security;
- godoc quality.
## Scope rules
- Always review the whole repository unless the prompt explicitly narrows scope.
- Check cross-package interactions, public APIs, package boundaries, and shared patterns.
- Prefer concrete fixes over generic advice.
- When feasible, make small, high-confidence improvements directly.
- When uncertain, state confidence level and evidence.
## Go review expectations
Check for:
- bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks;
- poor error handling;
- misuse of context, cancellation, timeouts, retries, and cleanup;
- race risks, deadlocks, blocking hazards, unsafe shared state;
- non-idiomatic naming, APIs, interfaces, package structure, and error patterns;
- unnecessary complexity, duplication, or weak abstractions;
- obvious performance problems supported by the code;
- security risks such as unsafe input handling, secret leakage, insecure logging, injection risks, and risky file or network operations.
## Godoc rules
Review comments for all declarations.
### Exported declarations
Exported types, funcs, methods, vars, and consts must have godoc comments.
Each exported godoc comment must:
- start with the identifier name;
- explain the purpose or behavior;
- be as short as possible without losing important meaning;
- avoid repeating the signature mechanically;
- stay high-signal and informative.
### Unexported declarations
Unexported types, funcs, methods, vars, and consts should generally not have godoc-style comments unless there is a strong reason.
### Always report
- missing godoc on exported declarations;
- unnecessary godoc on unexported declarations;
- comments that are too long, vague, redundant, or low-value;
- comments that should be shortened or rewritten.
When feasible, rewrite bad godoc into better versions.
## Testing expectations
Treat tests as a required part of review.
- Assess existing test quality, not only test presence.
- Add or propose as many useful tests as reasonably possible.
- Prioritize public APIs, critical flows, edge cases, negative paths, boundary conditions, and concurrency-sensitive logic.
- Prefer table-driven tests where appropriate.
- Add regression tests for bugs you find.
- If a case is hard to test directly, explain the gap and the best test strategy.
## Commands
Before finalizing changes, run the relevant project checks when available:
- build
- tests
- lint
- static analysis
Prefer the repositorys documented commands. If multiple choices exist, use the most standard and least destructive ones first.
## Output format
For repo-wide review tasks, structure the result as:
1. Overall summary
2. Critical findings
3. Major findings
4. Minor findings
5. Godoc issues
6. Test gaps and added/proposed tests
7. Good decisions worth keeping
8. Summary of concrete changes made
For each finding include:
- location;
- issue;
- why it matters;
- recommended fix.
## Working style
- Be direct, specific, and action-oriented.
- Do not stop at style-only feedback.
- Use full repository context before drawing conclusions.
- Prefer minimal, high-confidence patches.
- Preserve behavior unless intentionally fixing a bug.
+151
View File
@@ -0,0 +1,151 @@
# Changelog
## v1.0.0-rc.11
### Fixed
- `chat_boost` update decoding now accepts string `boost_id` values, matching the current Telegram Bot API schema and preventing polling failures on boosted-chat updates.
## v1.0.0-rc.10
### Added
- `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`.
- Derived `tgapi.Update.Type` assignment during JSON decoding, plus `tgapi.UpdateTypeUnknown` for unmatched payloads.
- `tgapi.API.OpenFileByLink(...)` and `OpenFileByLinkWithContext(...)` for streaming downloads from Telegram's file server.
- Regression tests for update dispatch, keyboard builders, localization fallback, runners, rate limiting, parse mode encoding, streaming downloads, and context isolation.
- Regression tests for bot single-run enforcement, nil plugin registration, `L10n` concurrent access, `API.Close()` idle-connection cleanup, and `tgapi` worker-pool edge cases.
- `SEMVER.md` documenting versioning expectations for the project.
### Changed
- `NewBot` now returns `(*Bot[T], error)` instead of terminating the host process on configuration or startup failures.
- `Run` and `RunWithContext` now return errors; `RunWithContext` returns `ErrNoPrefixes` and `ErrNoPlugins` for invalid bot configuration.
- Polling retries now use exponential backoff instead of busy-looping on repeated `getUpdates` failures.
- `Bot` is now explicitly single-use; repeated `Run()` or `RunWithContext(...)` calls return `ErrBotAlreadyRun`.
- Database context wiring now uses `T` consistently instead of forcing `*T`; shared dependencies should typically use pointer types such as `*sql.DB`.
- `DatabaseContext`, `GetDBContext`, and `DbLogger` were updated to the new `T`-based dependency model.
- `DatabaseContext(...)` now warns once when `T` is a value type, to highlight likely unintended copying of shared dependencies.
- `AddDatabaseLoggerWriter(...)` now skips unset and nil database contexts instead of calling the writer with invalid values.
- `L10n` is now safe for concurrent use and copies added dictionary entries to avoid external mutation after registration.
- Plugin registration now snapshots commands, payloads, middlewares, and update handlers so later mutations of the original `*Plugin` do not leak into the bot.
- `AddPlugins(...)` now skips nil plugin pointers instead of panicking.
- `GetUpdateTypes()` now returns a copy instead of exposing internal slice state.
- Update handling now normalizes `MsgContext` for more Telegram update kinds and routes plugin-level update handlers with isolated context copies.
- `message`, `channel_post`, and `callback_query` remain on the command/payload flow; non-command updates can be handled through plugin update handlers.
- Command auto-generation now validates Telegram command names with the correct character set and `1..32` length limit, and emits commands in deterministic sorted order.
- Builder-style APIs were normalized to value returns for `NewCommandArg`, `NewMiddleware`, `NewRunner`, and `NewCallbackData`.
- `MenuButton` replaced `BaseMenuButton`, and `GetChatMenuButton(...)` now returns the renamed type.
- Several Telegram DTOs were tightened for optionality and serialization correctness, including `InputPaidMedia`, `MenuButton`, optional gift fields, and message entity slices.
- `tgapi.NewRequest(...)`, `NewRequestWithChatID(...)`, `NewUploaderRequest(...)`, and `NewUploaderRequestWithChatID(...)` are now documented as low-level unsafe escape hatches rather than internal helpers.
- `tgapi.API.Close()` now closes idle HTTP connections before releasing logger resources.
- Multipart form encoding now writes scalar field bytes directly instead of converting through temporary strings.
- README, README_RU, package docs, and exported godoc were updated to match the current APIs and concurrency/lifecycle model.
- Version constants were bumped to `v1.0.0-rc.10`.
### Fixed
- Required command arguments are now enforced by declared argument index, not only by total required count.
- `ParseNone` now omits `parse_mode` from JSON requests instead of serializing `"None"`.
- Upload file type detection is now case-insensitive for file extensions.
- Draft creation no longer panics when no limiter is configured, and draft flushing now rejects zero chat IDs before sending invalid requests.
- Channel posts with `SenderChat` no longer panic in the command path and now preserve the expected `MsgContext` fields.
- File logger initialization now falls back to stdout loggers instead of terminating the process on logger setup failures.
- `GetChatMenuButton` and `SetChatMenuButton` now serialize `chat_id` correctly when omitted.
- Update decoding tests now match the canonical `deleted_business_messages` model and no longer rely on the removed singular alias.
### Breaking Changes
- `NewBot[T](opts)` now returns `(*Bot[T], error)`.
- `Run()` now returns `error`.
- `RunWithContext(ctx)` now returns `error`.
- `Run()` and `RunWithContext(ctx)` are now single-use per bot instance; create a new `Bot` after they return.
- Database context handlers now receive `T` instead of `*T`. For shared dependencies, instantiate the bot with a pointer type, for example `Bot[*sql.DB]`.
- `DatabaseContext(...)` now takes `T` instead of `*T`.
- `GetDBContext()` now returns `T` instead of `*T`.
- `DbLogger[T]` now receives `T` instead of `*T`.
- `NewCommandArg(...)`, `NewMiddleware(...)`, `NewRunner(...)`, and `NewCallbackData(...)` now return values instead of pointers.
- `BaseMenuButton` was renamed to `MenuButton`, and `GetChatMenuButton(...)` now returns `MenuButton`.
- `tgapi.Update` no longer exposes the deprecated `DeletedBusinessMessage` alias; use `DeletedBusinessMessages`.
### Tests
- Added coverage for polling backoff helpers, command sorting, database logger safety checks, update handler routing, update-context isolation, channel posts with `SenderChat`, parse mode encoding, streaming downloads, and rate limiter behavior.
## v1.0.0-rc.7
### Added
- Package-level logger helpers: `utils.CreateLogger(prefix, level)` and `utils.CreateFileLogger(prefix, level, filePath)`.
- `MsgContext.Logger`, populated from the matched plugin and falling back to the bot logger.
- Plugin lifecycle/configuration APIs: `SetLogger`, `RemoveLogger`, `SetOnClose`, and `Close`.
- `Bot.CloseRemote(ctx)` as the explicit wrapper for Telegram Bot API close.
### Changed
- Logger initialization is now unified across `Bot`, `tgapi.API`, and `tgapi.Uploader`.
- `Bot.Close()` now performs local resource teardown only and invokes `Plugin.Close()` for registered plugins.
- Local `tgapi.API` shutdown was renamed to `Close()`.
- Telegram Bot API close wrappers in `tgapi.API` were renamed to `CloseRemote()` and `CloseRemoteWithContext()`.
- `Bot.Debug()` now updates log levels for the bot logger, request logger, and already registered plugin loggers.
- `Bot.AddPlugins()` now creates a default plugin logger automatically when one is not provided.
- `Bot.AddDatabaseLoggerWriter()` now also attaches the writer to already registered plugin loggers.
- GoDoc was expanded for the new shutdown and logging APIs, and plugin registration is now documented as a configuration commit point.
### Breaking Changes
- `(*Bot).Close(ctx context.Context)` was replaced with `(*Bot).Close()`.
- `(*tgapi.API).CloseApi()` was renamed to `(*tgapi.API).Close()`.
- `(*tgapi.API).Close()` was renamed to `(*tgapi.API).CloseRemote()`.
- `(*tgapi.API).CloseWithContext()` was renamed to `(*tgapi.API).CloseRemoteWithContext(ctx)`.
### Migration
- Replace `bot.Close(ctx)` with `bot.Close()`.
- If you need Telegram Bot API close, use `bot.CloseRemote(ctx)`.
- Replace `api.CloseApi()` with `api.Close()`.
- Replace `api.Close()` with `api.CloseRemote()`.
- Replace `api.CloseWithContext(ctx)` with `api.CloseRemoteWithContext(ctx)`.
- Configure plugin loggers and `OnClose` hooks before calling `bot.AddPlugins(...)`.
### Tests
- Updated tests for the new shutdown and logging behavior.
### Notes
- Registering a plugin via `AddPlugins(...)` is a configuration commit point; the plugin should not be mutated through the original `*Plugin` afterward.
- If plugin loggers must receive a database writer, call `AddDatabaseLoggerWriter(...)` after registering plugins.
## v1.0.0-rc.4
### Added
- `WithContext` variants across `tgapi` API and uploader methods so callers can pass cancellation and deadline contexts consistently.
- `UploaderCertificateType`, `UploadSetWebhookP`, `Uploader.SetWebhook(...)`, and `Uploader.SetWebhookWithContext(...)` for multipart webhook certificate uploads.
- Missing media thumbnail fields where applicable.
### Changed
- GoDoc for context-aware methods was improved, and `See` references now point to method-specific Telegram Bot API anchors.
- `EditMessageTextP` now includes `entities` and `link_preview_options`.
- `EditMessageCaptionP` now includes `caption_entities` and `show_caption_above_media`.
- `StopPollP` now uses `reply_markup` and no longer carries `inline_message_id`.
- `SendStickerP` now includes reply and suggested-post related fields.
- `SendDocumentP` now includes `disable_content_type_detection`.
- `SendInvoiceP` no longer includes unsupported `business_connection_id`.
- `SetWebhookP` no longer carries `certificate`; GoDoc now points to uploader-based certificate upload.
- Existing non-context methods remain available, and the `Do(...)` call style is preserved.
### Breaking Changes
- Users sending webhook certificates through JSON `SetWebhookP.Certificate` must migrate to `Uploader.SetWebhook(...)`.
## v1.0.0-rc.3
### Fixed
- The update polling loop no longer logs or retries after `context.Canceled` during shutdown.
- Extra retry delay was removed from canceled polling requests so `RunWithContext` can exit immediately while stopping.
### Changed
- Shutdown behavior remains explicit: callers are still responsible for invoking `Close()` after `RunWithContext` returns.
## v1.0.0-rc.2
### Fixed
- Fixed a shutdown crash caused by `DatabaseWriter` calling `Close()` through an uninitialized embedded logger writer.
- Fixed bot shutdown hanging during Telegram long polling by making update polling use a cancelable context.
- Reduced the chance of container termination with exit code `137` during shutdown by allowing `getUpdates` to stop promptly on cancellation.
### Changed
- Switched the project to use the local `laniakea` replacement for the shutdown fix.
- Documentation now clarifies that `RunWithContext` does not close resources automatically and callers must invoke `Close()` explicitly.
- `Updates` documentation now describes context-driven cancellation behavior.
### Tests
- Added regression tests for database logger writer shutdown behavior.
-15
View File
@@ -1,15 +0,0 @@
# Проверка наличия golangci-lint
GO_LINT := $(shell command -v golangci-lint 2>/dev/null)
# Цель: запуск всех проверок кода
check:
@echo "🔍 Running code checks..."
@go mod tidy -v
@go vet ./...
@if [ -n "$(GO_LINT)" ]; then \
echo "✅ golangci-lint found, running..." && \
golangci-lint run --timeout=5m --verbose; \
else \
echo "⚠️ golangci-lint not installed. Install with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.57.2"; \
fi
@go test -race -v ./... 2>/dev/null || echo "⚠️ Tests skipped or failed (run manually with 'go test -race ./...')"
+42 -13
View File
@@ -1,5 +1,7 @@
# Laniakea
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases)
@@ -28,6 +30,12 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
go get git.nix13.pw/scuroneko/laniakea
```
or
```bash
go get github.com/scuroneko/laniakea
```
## 🚀 Quick Start (with step-by-step explanation)
Here is a minimal echo/ping bot example with detailed comments.
@@ -44,7 +52,7 @@ import (
// It receives two parameters:
// - ctx: the message context (contains info about the message, sender, chat, etc.)
// - db: your custom database context (here we use NoDB, a placeholder for no database)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
// Answer the user with the text they sent, without any command prefix.
// ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command
@@ -56,7 +64,10 @@ func main() {
// 2. Initialize a new bot instance.
// We use laniakea.NoDB as the database context type (no database needed for this example).
bot := laniakea.NewBot[laniakea.NoDB](opts)
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
if err != nil {
log.Fatal(err)
}
// Ensure bot resources are cleaned up on exit.
defer bot.Close()
@@ -70,7 +81,7 @@ func main() {
// 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
ctx.Answer("Pong")
}, "ping"))
@@ -86,7 +97,9 @@ func main() {
}
// 8. Start the bot, listening for updates (long polling).
bot.Run()
if err := bot.Run(); err != nil {
log.Fatal(err)
}
}
```
@@ -97,15 +110,16 @@ func main() {
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash).
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T.
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands.
8. `Run()`: Starts the bot's update polling loop.
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session.
## 📖 Core Concepts
### Plugins
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin)
```
@@ -145,14 +159,20 @@ Provides access to the incoming message and useful reply methods:
This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`.
For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves.
### Database Context
The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler.
The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type.
```go
type MyDB struct { /* ... */ }
db := &MyDB{...}
bot := laniakea.NewBot[*MyDB](opts, db) // Pass db instance
bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.DatabaseContext(db)
```
## 🧩 Middleware
@@ -169,11 +189,12 @@ func(ctx *MsgContext, db T) bool
- If it returns false, the execution chain stops immediately (the command will not run).
### Adding Middleware
Use the Use method of a plugin to add one or more middleware functions. They are executed in the order they are added.
Use `AddMiddleware` on a plugin to add one or more shared middleware functions. They are executed in the order they are added.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware)
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
```
@@ -204,7 +225,15 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
## ⚙️ Advanced Configuration
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`.
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control.
- **Localization**: `L10n` is safe for concurrent use once attached to the bot.
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
- **Lifecycle**: `RunWithContext(...)` does not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run.
## Telegram Update Handling
- Commands and payloads are handled through plugins.
- Non-command updates can be routed with `plugin.AddUpdateHandler(updateType, handler)`.
- `message`, `channel_post`, and `callback_query` stay on the command/payload flow.
- `tgapi.Update` exposes a derived `Type` field after JSON unmarshalling so handlers can inspect the effective update kind directly.
## 📝 License
+56 -20
View File
@@ -1,10 +1,12 @@
# Laniakea
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases)
Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке中间件, автоматической генерации команд и встроенному ограничителю скорости запросов.
Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
[English](README.md)
@@ -29,6 +31,12 @@
go get git.nix13.pw/scuroneko/laniakea
```
или
```bash
go get github.com/scuroneko/laniakea
```
## 🚀 Быстрый старт (с пошаговыми комментариями)
Вот минимальный пример бота "echo/ping" с подробными комментариями.
@@ -45,7 +53,7 @@ import (
// Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) {
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
@@ -57,7 +65,10 @@ func main() {
// 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера).
bot := laniakea.NewBot[laniakea.NoDB](opts)
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
if err != nil {
log.Fatal(err)
}
// Гарантируем освобождение ресурсов бота при выходе.
defer bot.Close()
@@ -71,7 +82,7 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) {
ctx.Answer("Pong")
}, "ping"))
@@ -87,7 +98,9 @@ func main() {
}
// 8. Запускаем бота, начиная прослушивание обновлений (long polling).
bot.Run()
if err := bot.Run(); err != nil {
log.Fatal(err)
}
}
```
@@ -98,15 +111,16 @@ func main() {
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд.
8. `Run()`: Запускает цикл опроса обновлений бота.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
## 📖 Основные концепции
### Плагины (Plugins)
Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin)
```
@@ -130,21 +144,34 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки.
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей!
### Контекст базы данных (Database Context)
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type.
```go
type MyDB struct { /* ... */ }
db := &MyDB{...}
bot := laniakea.NewBot[*MyDB](opts, db) // Передаём экземпляр db
bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.DatabaseContext(db)
```
### tgapi: API и Uploader
В `tgapi` есть два клиента:
- `API` для JSON-запросов (`SendMessage`, `EditMessageText`, методы с `file_id`/URL).
- `Uploader` для multipart-загрузок (`SendPhoto`, `SendDocument`, `SendVideo` с бинарными файлами).
Для продвинутых сценариев `tgapi.NewRequest(...)` и `tgapi.NewUploaderRequest(...)` остаются публичными low-level escape hatch API. Они менее безопасны, чем типизированные helper-методы: вызывающая сторона сама отвечает за корректное имя Telegram-метода и совместимые типы параметров/ответа.
## 🧩 Промежуточные слои (Middleware)
Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста.
@@ -159,11 +186,12 @@ func(ctx *MsgContext, db T) bool
- Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается).
### Добавление middleware
Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления.
Используйте метод `AddMiddleware` плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware)
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
```
@@ -192,9 +220,17 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
## ⚙️ Расширенная настройка
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard().
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`.
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
- **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
- **Жизненный цикл**: `RunWithContext(...)` не вызывает `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска.
## Обработка Telegram Updates
- Команды и payload-ы обрабатываются через плагины.
- Для некомандных update-ов можно зарегистрировать обработчик через `plugin.AddUpdateHandler(updateType, handler)`.
- `message`, `channel_post` и `callback_query` остаются в command/payload flow.
- После JSON-декодирования `tgapi.Update` заполняет поле `Type`, чтобы обработчики могли явно видеть итоговый вид update.
## 📝 Лицензия
Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE).
+45
View File
@@ -0,0 +1,45 @@
# Semantic Versioning Policy
This project follows Semantic Versioning with the rules below.
## Public API Surface
The public API consists of:
- exported identifiers in package `laniakea`
- exported identifiers in package `tgapi`
- documented behavior in `README.md`, `README_RU.md`, and package godoc
Anything unexported is internal and may change without notice.
## Breaking Changes
A release requires a major version bump when it changes any of the following:
- exported function, method, type, field, constant, or variable names
- function or method signatures
- JSON field names or request/response wire compatibility in `tgapi`
- documented behavioral guarantees relied on by callers
Examples:
- removing an exported alias
- changing callback payload encoding defaults
- changing handler dispatch semantics in a way that breaks existing bots
## Minor Changes
A release uses a minor version bump for backward-compatible additions:
- new exported types, methods, helpers, or update handlers
- support for new Telegram Bot API fields or methods
- optional configuration knobs that do not change existing defaults
## Patch Changes
A release uses a patch version bump for backward-compatible fixes:
- bug fixes
- test-only changes
- godoc and README clarifications
- internal refactors with no public behavior change
## Pre-Releases
`-rc.N` builds may still adjust API details before `v1.0.0`.
Once `v1.0.0` is released, breaking changes require a new major version.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

+293 -66
View File
@@ -2,7 +2,9 @@ package laniakea
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"strings"
"sync"
@@ -15,13 +17,18 @@ import (
"github.com/alitto/pond/v2"
)
// DbContext is an interface representing the application's database context.
// It is injected into plugins and middleware via Bot.DatabaseContext().
// DbContext is the generic dependency type injected into bots, plugins, and handlers.
// Use it for shared application state such as database handles or service containers.
//
// Example:
//
// type MyDB struct { ... }
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
// myDB := &MyDB{}
// bot, err := NewBot[*MyDB](opts)
// if err != nil {
// return err
// }
// bot.DatabaseContext(myDB)
//
// Use NoDB if no database is needed.
type DbContext any
@@ -32,7 +39,7 @@ type NoDB struct{ DbContext }
// DbLogger is a function type that returns a slog.LoggerWriter for database logging.
// Used to inject database-specific log output (e.g., SQL queries, ORM events).
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter
type DbLogger[T DbContext] func(db T) slog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string
@@ -44,6 +51,20 @@ var (
BotPayloadJson BotPayloadType = "json"
)
var (
// ErrNoPrefixes reports that the bot was started without any command prefixes.
ErrNoPrefixes = errors.New("no prefixes defined")
// ErrNoPlugins reports that the bot was started without any registered plugins.
ErrNoPlugins = errors.New("no plugins defined")
// ErrBotAlreadyRun reports that Run or RunWithContext was called more than once.
ErrBotAlreadyRun = errors.New("bot can only be run once")
// ErrTokenRequired reports that BotOpts.Token was empty.
ErrTokenRequired = errors.New("token required")
// ErrOptsIsNil reports that NewBot was called with a nil BotOpts pointer.
ErrOptsIsNil = errors.New("opts is nil")
)
// Bot is the core Telegram bot instance.
//
// Manages:
@@ -53,7 +74,8 @@ var (
// - Logging and rate limiting
// - Localization and draft message support
//
// All methods are safe for concurrent use. Direct field access is not recommended.
// Runtime accessors are safe for concurrent use. Configure the bot before Run.
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
type Bot[T DbContext] struct {
token string
debug bool
@@ -73,9 +95,11 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader
dbContext *T // Injected database context
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
dbContext T // Injected database context
hasDBContext bool
warnedValueDB bool
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID
@@ -83,6 +107,9 @@ type Bot[T DbContext] struct {
updateQueue chan *tgapi.Update // Internal queue for processing updates
runnerOnceWG sync.WaitGroup // Tracks one-time async runners
runnerBgWG sync.WaitGroup // Tracks background async runners
runStateMu sync.Mutex
running bool
ran bool
}
// NewBot creates and initializes a new Bot instance using the provided BotOpts.
@@ -93,13 +120,12 @@ type Bot[T DbContext] struct {
// - Fetches bot username via GetMe()
// - Sets up DraftProvider with random IDs
// - Adds API and Uploader loggers to extraLoggers
//
// Panics if:
// - Token is empty
// - GetMe() fails (invalid token or network error)
func NewBot[T any](opts *BotOpts) *Bot[T] {
func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
if opts == nil {
return nil, ErrOptsIsNil
}
if opts.Token == "" {
panic("laniakea: BotOpts.Token is required")
return nil, ErrTokenRequired
}
updateQueue := make(chan *tgapi.Update, 512)
@@ -163,7 +189,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
u, err := api.GetMe()
if err != nil {
_ = bot.Close()
bot.logger.Fatal(err)
return nil, err
}
bot.username = Val(u.Username, "")
if bot.username == "" {
@@ -171,68 +197,89 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
}
bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown")))
return bot
return bot, nil
}
// Close gracefully shuts down the bot.
// Close gracefully shuts down bot-owned resources.
//
// Closes:
// Close shuts down, in order:
// - Registered plugins via Plugin.Close
// - Uploader (waits for pending uploads)
// - API client
// - API client internals
// - RequestLogger (if enabled)
// - Main logger
//
// Returns the first error encountered, if any.
// RunWithContext does not call Close automatically. The caller is responsible
// for invoking Close after RunWithContext returns to release these resources.
//
// Close returns a joined error containing all shutdown failures, if any.
func (bot *Bot[T]) Close() error {
var e []error
for _, p := range bot.plugins {
if err := p.Close(); err != nil {
e = append(e, err)
}
}
if err := bot.uploader.Close(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
}
if err := bot.api.CloseApi(); err != nil {
if err := bot.api.Close(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
}
if bot.RequestLogger != nil {
if err := bot.RequestLogger.Close(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
}
}
if err := bot.logger.Close(); err != nil {
e = append(e, err)
}
return errors.Join(e...)
}
// CloseRemote sends Telegram Bot API "close" request for the current bot
// instance using ctx for cancellation and deadlines.
//
// This is separate from Bot.Close(), which only releases local resources.
func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
if _, err := bot.api.CloseRemoteWithContext(ctx); err != nil {
return err
}
return nil
}
// initLoggers configures the main and optional request loggers.
//
// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise).
// Writes to stdout in JSON format by default.
// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath.
// Internal logger setup for the bot and optional request logger.
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = slog.CreateLogger().Level(level).Prefix("BOT")
bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter())
bot.logger = utils.CreateLogger("BOT", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
fileWriter, err := bot.logger.CreateTextFileWriter(path)
logger, err := utils.CreateFileLogger("BOT", level, path)
if err != nil {
bot.logger.Fatal(err)
bot.logger.Errorln(err)
} else {
bot.logger = logger
}
bot.logger.AddWriter(fileWriter)
}
if opts.UseRequestLogger {
bot.RequestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS")
bot.RequestLogger.AddWriter(bot.RequestLogger.CreateJsonStdoutWriter())
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
fileWriter, err := bot.RequestLogger.CreateTextFileWriter(path)
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
if err != nil {
bot.logger.Fatal(err)
bot.logger.Errorln(err)
} else {
bot.RequestLogger = logger
}
bot.RequestLogger.AddWriter(fileWriter)
}
}
}
@@ -252,14 +299,26 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
}
// GetUpdateTypes returns the list of update types the bot is configured to receive.
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes }
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
return append([]tgapi.UpdateType(nil), bot.updateTypes...)
}
// GetLogger returns the main bot logger.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetDBContext returns the injected database context.
// Returns nil if not set via DatabaseContext().
func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext }
// If DatabaseContext was not called, it returns the zero value of T.
func (bot *Bot[T]) GetDBContext() T { return bot.dbContext }
// GetLoggerLevel returns the effective log level derived from the bot's debug
// flag.
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
level := slog.FATAL
if bot.debug {
level = slog.DEBUG
}
return level
}
// L10n translates a key in the given language.
// Returns empty string if translation not found.
@@ -276,8 +335,16 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
// DatabaseContext injects a database context into the bot.
// This context is accessible to plugins and middleware via GetDBContext().
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] {
// For shared dependencies such as *sql.DB, prefer using a pointer type as T.
// Value-typed contexts are supported, but the bot warns once because handlers
// receive T by value.
func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] {
if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil {
bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T")
bot.warnedValueDB = true
}
bot.dbContext = ctx
bot.hasDBContext = true
return bot
}
@@ -322,15 +389,48 @@ func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] {
// Debug enables or disables debug logging.
func (bot *Bot[T]) Debug(debug bool) *Bot[T] {
bot.debug = debug
level := slog.FATAL
if debug {
level = slog.DEBUG
}
bot.logger.Level(level)
if bot.RequestLogger != nil {
bot.RequestLogger.Level(level)
}
for _, p := range bot.plugins {
if p.logger == nil {
continue
}
p.logger.Level(level)
}
return bot
}
// AddPlugins registers one or more plugins.
// Plugins are executed in registration order unless filtered by middleware.
//
// Registration is a commit point for plugin configuration. The Bot stores
// plugin metadata internally, so plugins must be fully configured before they
// are passed here. Post-registration mutation through the original *Plugin is
// not a supported API, even if some changes appear to work due to shared maps.
func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
level := bot.GetLoggerLevel()
for _, p := range plugin {
bot.plugins = append(bot.plugins, *p)
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name))
if p == nil {
if bot.logger != nil {
bot.logger.Warn("nil plugin skipped")
}
continue
}
cloned := clonePlugin(p)
if cloned.logger == nil {
cloned.logger = utils.CreateLogger(cloned.name, level)
}
bot.plugins = append(bot.plugins, cloned)
if bot.logger != nil {
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name))
}
}
return bot
}
@@ -347,13 +447,14 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
//
// Example:
//
// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware)
// bot.AddMiddleware(authMiddleware, rateLimitMiddleware)
//
// Panics if any middleware has a nil name.
// Middleware with an empty name are skipped with a warning.
func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
for _, m := range middleware {
if m.name == "" {
panic("laniakea: middleware must have a non-empty name")
bot.logger.Warnln("middleware must have a non-empty name")
continue
}
bot.middlewares = append(bot.middlewares, m)
bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name))
@@ -384,12 +485,13 @@ func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
//
// Example:
//
// bot.AddRunner(&cleanupRunner)
// bot.AddRunner(cleanupRunner)
//
// Panics if runner has a nil name.
// Runners with an empty name are skipped with a warning.
func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
if runner.name == "" {
panic("laniakea: runner must have a non-empty name")
bot.logger.Warnln("runner must have a non-empty name")
return bot
}
bot.runners = append(bot.runners, runner)
bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name))
@@ -424,6 +526,11 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
// - Main bot logger
// - Request logger (if enabled)
// - API and Uploader loggers
// - Already registered plugin loggers
//
// Call this after AddPlugins if plugin loggers should also receive the writer.
// Plugins registered later do not automatically inherit previously added
// database writers; call AddDatabaseLoggerWriter again after adding them.
//
// Example:
//
@@ -431,6 +538,14 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
// return db.QueryLogger()
// })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
if !bot.hasDBContext {
bot.logger.Warnln("database context is not set; skipping database logger writer")
return bot
}
if isNilValue(bot.dbContext) {
bot.logger.Warnln("database context is nil; skipping database logger writer")
return bot
}
w := writer(bot.dbContext)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
@@ -439,6 +554,11 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot
}
@@ -453,7 +573,10 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// The context controls graceful shutdown. When canceled, the bot:
// - Stops polling for new updates
// - Finishes processing currently queued updates
// - Closes all resources (API, uploader, loggers)
// - Waits for registered runners to exit
//
// RunWithContext does not close API, uploader, or logger resources on return.
// The caller must invoke Close after RunWithContext finishes.
//
// Example:
//
@@ -461,22 +584,21 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// go bot.RunWithContext(ctx)
// // ... later ...
// cancel() // triggers graceful shutdown
func (bot *Bot[T]) RunWithContext(ctx context.Context) {
defer func() {
if err := bot.Close(); err != nil {
bot.logger.Errorln(err)
}
}()
// _ = bot.Close()
//
// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun.
func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
if len(bot.prefixes) == 0 {
bot.logger.Fatalln("no prefixes defined")
return
return ErrNoPrefixes
}
if len(bot.plugins) == 0 {
bot.logger.Fatalln("no plugins defined")
return
return ErrNoPlugins
}
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
bot.ExecRunners(ctx)
@@ -490,20 +612,34 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
}
close(bot.updateQueue)
}()
retryDelay := time.Duration(0)
for {
select {
case <-ctx.Done():
return
default:
updates, err := bot.Updates()
updates, err := bot.Updates(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
bot.logger.Errorln("failed to fetch updates:", err)
time.Sleep(2 * time.Second) // exponential backoff
retryDelay = nextPollRetryDelay(retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
continue
}
retryDelay = 0
for _, u := range updates {
u := u // copy loop variable to avoid race condition
for _, update := range updates {
u := update // copy loop variable to avoid race condition
select {
case bot.updateQueue <- &u:
case <-ctx.Done():
@@ -525,6 +661,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
pool.Stop() // Wait for all tasks to complete and stop the pool
bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait()
return nil
}
// Run starts the bot using a background context.
@@ -533,6 +670,96 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
// Use this for simple bots where graceful shutdown is not required.
//
// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully.
func (bot *Bot[T]) Run() {
bot.RunWithContext(context.Background())
func (bot *Bot[T]) Run() error {
return bot.RunWithContext(context.Background())
}
func (bot *Bot[T]) beginRun() error {
bot.runStateMu.Lock()
defer bot.runStateMu.Unlock()
if bot.running || bot.ran {
return ErrBotAlreadyRun
}
bot.running = true
bot.ran = true
return nil
}
func (bot *Bot[T]) finishRun() {
bot.runStateMu.Lock()
bot.running = false
bot.runStateMu.Unlock()
}
func nextPollRetryDelay(prev time.Duration) time.Duration {
if prev <= 0 {
return time.Second
}
next := prev * 2
if next > 30*time.Second {
return 30 * time.Second
}
return next
}
func isNilValue[T any](v T) bool {
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return true
}
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
func shouldWarnOnValueDBContext[T any]() bool {
t := reflect.TypeFor[T]()
if t == reflect.TypeFor[NoDB]() {
return false
}
switch t.Kind() {
case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan:
return false
default:
return true
}
}
func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
cloned := Plugin[T]{
name: p.name,
commands: make(map[string]*Command[T], len(p.commands)),
payloads: make(map[string]*Command[T], len(p.payloads)),
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
skipAutoCmd: p.skipAutoCmd,
logger: p.logger,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
onClose: p.onClose,
}
for name, command := range p.commands {
cloned.commands[name] = cloneCommand(command)
}
for name, command := range p.payloads {
cloned.payloads[name] = cloneCommand(command)
}
for t, handler := range p.handlers {
cloned.handlers[t] = handler
}
return cloned
}
func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
if command == nil {
return nil
}
cloned := *command
cloned.args = append(extypes.Slice[CommandArg](nil), command.args...)
cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...)
return &cloned
}
+40 -8
View File
@@ -11,7 +11,7 @@ import (
// BotOpts holds configuration options for initializing a Bot.
//
// Values are loaded from environment variables via LoadOptsFromEnv().
// Use NewOpts() to create a zero-value struct and set fields manually.
// Use &BotOpts{} to create a value and set fields manually.
type BotOpts struct {
// Token is the Telegram bot token (required).
Token string
@@ -56,7 +56,7 @@ type BotOpts struct {
// Use this to prioritize responsiveness over reliability.
DropRLOverflow bool
// MaxWorkers is the maximum number of concurrency running update handlers.
// MaxWorkers is the maximum number of update handlers that may run concurrently.
MaxWorkers int
}
@@ -75,20 +75,30 @@ type BotOpts struct {
// - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30)
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
//
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined.
// Returns a populated BotOpts.
// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing.
func LoadOptsFromEnv() *BotOpts {
rateLimit := 30
maxWorkers := 32
stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES"))
updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes))
for _, updateType := range stringUpdateTypes {
updateTypes = append(updateTypes, tgapi.UpdateType(updateType))
}
if rl := os.Getenv("RATE_LIMIT"); rl != "" {
if n, err := strconv.Atoi(rl); err == nil {
rateLimit = n
}
}
stringUpdateTypes := strings.Split(os.Getenv("UPDATE_TYPES"), ";")
updateTypes := make([]tgapi.UpdateType, len(stringUpdateTypes))
for i, updateType := range stringUpdateTypes {
updateTypes[i] = tgapi.UpdateType(updateType)
if mw := os.Getenv("MAX_WORKERS"); mw != "" {
if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil {
maxWorkers = n
}
}
return &BotOpts{
@@ -108,6 +118,8 @@ func LoadOptsFromEnv() *BotOpts {
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
MaxWorkers: maxWorkers,
}
}
@@ -222,5 +234,25 @@ func LoadPrefixesFromEnv() []string {
if !exists {
return []string{"/"}
}
return strings.Split(prefixesS, ";")
prefixes := splitEnvList(prefixesS)
if len(prefixes) == 0 {
return []string{"/"}
}
return prefixes
}
func splitEnvList(value string) []string {
if value == "" {
return nil
}
parts := strings.Split(value, ";")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}
+47
View File
@@ -0,0 +1,47 @@
package laniakea
import (
"reflect"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
t.Setenv("UPDATE_TYPES", "")
opts := LoadOptsFromEnv()
if len(opts.UpdateTypes) != 0 {
t.Fatalf("expected no update types, got %v", opts.UpdateTypes)
}
}
func TestLoadOptsFromEnvSplitsAndTrimsUpdateTypes(t *testing.T) {
t.Setenv("UPDATE_TYPES", "message; ; callback_query ")
opts := LoadOptsFromEnv()
want := []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery}
if !reflect.DeepEqual(opts.UpdateTypes, want) {
t.Fatalf("unexpected update types: got %v want %v", opts.UpdateTypes, want)
}
}
func TestLoadPrefixesFromEnvDefaultsOnEmptyValue(t *testing.T) {
t.Setenv("PREFIXES", "")
got := LoadPrefixesFromEnv()
want := []string{"/"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected prefixes: got %v want %v", got, want)
}
}
func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
t.Setenv("PREFIXES", "/; ; ! ")
got := LoadPrefixesFromEnv()
want := []string{"/", "!"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected prefixes: got %v want %v", got, want)
}
}
+201
View File
@@ -0,0 +1,201 @@
package laniakea
import (
"context"
"errors"
"path/filepath"
"reflect"
"testing"
"time"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
)
func TestGetUpdateTypesReturnsCopy(t *testing.T) {
bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}}
got := bot.GetUpdateTypes()
got[0] = tgapi.UpdateTypeCallbackQuery
if want := []tgapi.UpdateType{tgapi.UpdateTypeMessage}; !reflect.DeepEqual(bot.updateTypes, want) {
t.Fatalf("GetUpdateTypes exposed internal slice: got %v want %v", bot.updateTypes, want)
}
}
func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
bot.AddPlugins(plugin)
cmd.SetDescription("mutated after registration")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {}, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
registered := bot.plugins[0]
if _, exists := registered.commands["late"]; exists {
t.Fatal("late command leaked into registered plugin snapshot")
}
if registered.commands["start"].description != "" {
t.Fatalf("registered command description unexpectedly mutated: %q", registered.commands["start"].description)
}
if len(registered.middlewares) != 1 {
t.Fatalf("registered middlewares unexpectedly mutated: got %d want 1", len(registered.middlewares))
}
}
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo")
bot.AddPlugins(nil, plugin)
if len(bot.plugins) != 1 {
t.Fatalf("expected exactly one registered plugin, got %d", len(bot.plugins))
}
if bot.plugins[0].name != "demo" {
t.Fatalf("unexpected plugin name: %q", bot.plugins[0].name)
}
}
func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
bot := &Bot[NoDB]{}
bot.initLoggers(&BotOpts{
Debug: true,
WriteToFile: true,
UseRequestLogger: true,
LoggerBasePath: filepath.Join(t.TempDir(), "missing", "nested"),
})
if bot.logger == nil {
t.Fatal("expected main logger fallback")
}
if bot.RequestLogger == nil {
t.Fatal("expected request logger fallback")
}
if err := bot.RequestLogger.Close(); err != nil {
t.Fatalf("failed to close request logger: %v", err)
}
if err := bot.logger.Close(); err != nil {
t.Fatalf("failed to close main logger: %v", err)
}
}
func TestNextPollRetryDelay(t *testing.T) {
tests := []struct {
name string
prev time.Duration
want time.Duration
}{
{name: "initial", prev: 0, want: time.Second},
{name: "double", prev: 2 * time.Second, want: 4 * time.Second},
{name: "cap", prev: 20 * time.Second, want: 30 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextPollRetryDelay(tt.prev); got != tt.want {
t.Fatalf("nextPollRetryDelay(%s) = %s, want %s", tt.prev, got, tt.want)
}
})
}
}
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
called := false
bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter {
called = true
return nil
})
if called {
t.Fatal("expected database logger writer to be skipped when db context is unset")
}
}
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) {
type testDB struct{}
bot := &Bot[*testDB]{logger: slog.CreateLogger()}
var db *testDB
bot.DatabaseContext(db)
called := false
bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter {
called = true
return nil
})
if called {
t.Fatal("expected database logger writer to be skipped when db context is nil")
}
}
func TestShouldWarnOnValueDBContext(t *testing.T) {
type testDB struct{}
type dbIface interface{ Ping() error }
tests := []struct {
name string
got bool
want bool
}{
{name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false},
{name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true},
{name: "int", got: shouldWarnOnValueDBContext[int](), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want)
}
})
}
}
func TestDatabaseContextMarksValueWarningOnce(t *testing.T) {
type testDB struct{}
bot := &Bot[testDB]{logger: slog.CreateLogger()}
bot.DatabaseContext(testDB{})
if !bot.warnedValueDB {
t.Fatal("expected value-typed database context to mark warning state")
}
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
ptrBot.DatabaseContext(&testDB{})
if ptrBot.warnedValueDB {
t.Fatal("did not expect pointer-typed database context to mark warning state")
}
}
func TestRunWithContextRejectsSecondRun(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
}
if err := bot.RunWithContext(ctx); err != nil {
t.Fatalf("first RunWithContext returned error: %v", err)
}
if err := bot.RunWithContext(ctx); !errors.Is(err, ErrBotAlreadyRun) {
t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err)
}
}
+24 -17
View File
@@ -4,13 +4,14 @@ import (
"errors"
"fmt"
"regexp"
"sort"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
// CmdRegexp matches command names allowed for Telegram command registration.
var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$")
var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// ErrTooManyCommands is returned when the total number of registered commands
// exceeds Telegram's limit of 100 bot commands per bot.
@@ -20,7 +21,7 @@ var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$")
// bot initialization.
var ErrTooManyCommands = errors.New("too many commands. max 100")
// generateBotCommand builds a BotCommand description with generated usage text.
// Internal helper to build a BotCommand description with generated usage text.
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := ""
if len(cmd.description) > 0 {
@@ -44,13 +45,20 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
return tgapi.BotCommand{Command: cmd.command, Description: usage}
}
// checkCmdRegex reports whether cmd matches CmdRegexp.
// Internal helper to validate Telegram command names.
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin.
// Internal helper to collect non-skipped, valid commands from one plugin.
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, cmd := range pl.commands {
names := make([]string, 0, len(pl.commands))
for name := range pl.commands {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
cmd := pl.commands[name]
if cmd.skipAutoCmd {
continue
}
@@ -62,9 +70,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands
}
// gatherCommands collects all commands from all plugins
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin.
// Internal helper to collect all auto-generated commands from registered plugins.
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
@@ -100,17 +106,17 @@ func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
// log.Fatal(err)
// }
func (bot *Bot[T]) AutoGenerateCommands() error {
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
// Clear existing commands to avoid duplication or stale entries
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
// Register commands for each scope
scopes := []*tgapi.BotCommandScope{
{Type: tgapi.BotCommandScopePrivateType},
@@ -148,15 +154,16 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
// log.Fatal(err)
// }
func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) error {
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
commands := gatherCommands(bot)
if len(commands) > 100 {
return ErrTooManyCommands
}
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
Commands: commands,
Scope: scope,
+85
View File
@@ -0,0 +1,85 @@
package laniakea
import (
"errors"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"sync/atomic"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
var calls atomic.Int64
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
plugin := NewPlugin[NoDB]("overflow")
exec := func(ctx *MsgContext, db NoDB) {}
for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
}
bot := &Bot[NoDB]{
api: api,
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{*plugin},
}
err := bot.AutoGenerateCommands()
if !errors.Is(err, ErrTooManyCommands) {
t.Fatalf("expected ErrTooManyCommands, got %v", err)
}
if calls.Load() != 0 {
t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load())
}
}
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
plugin := NewPlugin[NoDB]("sorted")
exec := func(ctx *MsgContext, db NoDB) {}
plugin.AddCommand(NewCommand(exec, "zeta"))
plugin.AddCommand(NewCommand(exec, "alpha"))
plugin.AddCommand(NewCommand(exec, "mid"))
commands := gatherCommandsForPlugin(*plugin)
got := make([]string, 0, len(commands))
for _, cmd := range commands {
got = append(got, cmd.Command)
}
want := []string{"alpha", "mid", "zeta"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected command order: got %v want %v", got, want)
}
}
+18 -40
View File
@@ -1,55 +1,33 @@
/*
Package laniakea provides a modular, extensible framework for building scalable Telegram bots.
It offers a fluent API for configuration and separates concerns through several core concepts:
Core concepts:
- Bot: The central instance managing API communication, update processing, logging,
rate limiting, and dependency injection. Created via NewBot[T].
- Plugins: Organize commands and payloads into reusable units.
A plugin can have multiple commands and shared middlewares.
- Commands: Named bot commands with descriptions, argument validation, and
execution logic. Automatically registrable across different chat scopes.
- Middleware: Functions that intercept and modify updates before they reach plugins.
Useful for authentication, logging, validation, etc. Return false to stop processing.
- MsgContext: Provides access to the incoming update and convenient methods for
responding, editing, deleting, and translating messages. Includes built-in rate limiting
and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2().
- InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons,
icons, URLs, and structured callback data (JSON or Base64).
- DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation
(random or linear). Drafts can be built incrementally and flushed atomically.
- L10n: Simple key-based localization system with fallback language support.
- Runners: Background goroutines for periodic tasks or oneoff initialization,
with configurable timeouts and async execution.
- RateLimiting & Logging: Builtin rate limiter (respects Telegram's retry_after)
and structured logging (JSON stdout + optional file output) with requestlevel tracing.
- Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers
via the type parameter T in Bot[T].
- Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection.
- Plugins group commands, payloads, and non-command update handlers behind shared middleware.
- MsgContext provides access to the current update and reply/edit/delete helpers.
- InlineKeyboard builds callback-driven keyboards and structured payloads.
- DraftProvider accumulates multi-step replies before sending them.
- L10n stores key-based translations with fallback behavior.
- Runners execute startup or background tasks alongside the polling loop.
Example usage:
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
DatabaseContext(&myDB).
bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv())
if err != nil {
return err
}
bot.DatabaseContext(myDB).
AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(&authMiddleware, &logMiddleware).
AddRunner(&cleanupRunner).
AddMiddleware(authMiddleware, logMiddleware).
AddRunner(cleanupRunner).
AddL10n(l10n.New())
bot.Run()
return bot.Run()
All public methods are safe for concurrent use unless stated otherwise.
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset).
Configure bots, plugins, and localization before starting Run or RunWithContext.
Runtime accessors are safe for concurrent use unless stated otherwise.
*/
package laniakea
+8 -11
View File
@@ -12,7 +12,7 @@ import (
// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID.
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs.
// Interface for generating unique draft IDs.
type draftIdGenerator interface {
// Next returns the next unique draft ID.
Next() uint64
@@ -38,12 +38,9 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
return g.lastId.Add(1)
}
// DraftProvider manages a collection of Drafts and provides methods to create and
// configure them. It holds shared configuration (chat, parse mode, entities) and
// a draft ID generator.
// DraftProvider manages a collection of Drafts and a shared draft ID generator.
//
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
// requires external synchronization.
// DraftProvider is safe for concurrent use.
type DraftProvider struct {
mu sync.RWMutex
api *tgapi.API
@@ -133,10 +130,7 @@ type Draft struct {
// NewDraft creates a new draft with the provided parse mode.
//
// The draft inherits the provider's chatID, messageThreadID, and entities.
// If parseMode is zero, the provider's default parseMode is used.
//
// Panics if chatID is zero — call SetChat() on the provider first.
// The caller must set a chat with SetChat before Push or Flush.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
id := p.generator.Next()
draft := &Draft{
@@ -224,6 +218,9 @@ func (d *Draft) Flush() error {
if d.Message == "" {
return nil
}
if d.chatID == 0 {
return ErrDraftChatIDZero
}
params := tgapi.SendMessageP{
ChatID: d.chatID,
@@ -242,7 +239,7 @@ func (d *Draft) Flush() error {
return err
}
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft.
// Internal helper for Push that updates the server-side draft.
func (d *Draft) push(text string) error {
if d.chatID == 0 {
return ErrDraftChatIDZero
+36
View File
@@ -0,0 +1,36 @@
package laniakea
import (
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
)
func TestDraftFlushRequiresChatID(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
draft.Message = "hello"
if err := draft.Flush(); err != ErrDraftChatIDZero {
t.Fatalf("expected ErrDraftChatIDZero, got %v", err)
}
}
func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
ctx := &MsgContext{
Api: &tgapi.API{},
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
},
Logger: slog.CreateLogger(),
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
}
draft := ctx.NewDraft()
if draft == nil {
t.Fatal("expected draft")
}
if draft.chatID != 42 {
t.Fatalf("unexpected chat id: %d", draft.chatID)
}
}
+4 -2
View File
@@ -2,9 +2,11 @@ module git.nix13.pw/scuroneko/laniakea
go 1.26
retract v1.0.0-rc.5
require (
git.nix13.pw/scuroneko/extypes v1.2.1
git.nix13.pw/scuroneko/slog v1.0.2
git.nix13.pw/scuroneko/extypes v1.2.2
git.nix13.pw/scuroneko/slog v1.1.2
github.com/alitto/pond/v2 v2.7.0
golang.org/x/time v0.15.0
)
+4 -4
View File
@@ -1,7 +1,7 @@
git.nix13.pw/scuroneko/extypes v1.2.1 h1:IYrOjnWKL2EAuJYtYNa+luB1vBe6paE8VY/YD+5/RpQ=
git.nix13.pw/scuroneko/extypes v1.2.1/go.mod h1:uZVs8Yo3RrYAG9dMad6qR6lsYY67t+459D9c65QAYAw=
git.nix13.pw/scuroneko/slog v1.0.2 h1:vZyUROygxC2d5FJHUQM/30xFEHY1JT/aweDZXA4rm2g=
git.nix13.pw/scuroneko/slog v1.0.2/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI=
git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk=
git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk=
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0=
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM=
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+161 -24
View File
@@ -22,38 +22,47 @@ func (bot *Bot[T]) handle(u *tgapi.Update) {
ctx := &MsgContext{
Update: *u, Api: bot.api,
botLogger: bot.logger,
Logger: bot.logger,
errorTemplate: bot.errorTemplate,
l10n: bot.l10n,
draftProvider: bot.draftProvider,
payloadType: bot.payloadType,
}
bot.prepareUpdateCtx(u, ctx)
for _, middleware := range bot.middlewares {
if !middleware.Execute(ctx, bot.dbContext) {
return
}
}
if u.CallbackQuery != nil {
bot.handleCallback(u, ctx)
} else {
switch u.Type {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
bot.handleMessage(u, ctx)
case tgapi.UpdateTypeCallbackQuery:
bot.handleCallback(u, ctx)
default:
bot.handleUpdate(u, ctx)
}
}
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if update.Message == nil {
return
}
if update.Message.From == nil {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return
}
var text string
if len(update.Message.Text) > 0 {
text = update.Message.Text
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
text = update.Message.Caption
return
}
text = strings.TrimSpace(text)
@@ -61,10 +70,9 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if !hasPrefix {
return
}
ctx.Prefix = prefix
ctx.FromID = update.Message.From.ID
ctx.From = update.Message.From
ctx.Msg = update.Message
ctx.Update = *update
// Убираем префикс
text = strings.TrimSpace(text[len(prefix):])
@@ -94,6 +102,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if plugin.logger != nil {
ctx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
@@ -110,16 +122,6 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
return
}
ctx.FromID = update.CallbackQuery.From.ID
ctx.From = &update.CallbackQuery.From
if update.CallbackQuery.Message != nil {
ctx.Msg = update.CallbackQuery.Message
ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID
}
if update.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = update.CallbackQuery.ID
ctx.Args = data.Args
for _, plugin := range bot.plugins {
@@ -128,6 +130,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
continue
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
@@ -136,8 +142,139 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
}
}
func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
for _, plugin := range bot.plugins {
handler, ok := plugin.handlers[u.Type]
if !ok {
continue
}
pluginCtx := cloneMsgContext(ctx)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
continue
}
handler(pluginCtx, bot.dbContext)
}
}
func cloneMsgContext(src *MsgContext) *MsgContext {
cloned := *src
if src.Args != nil {
cloned.Args = append([]string(nil), src.Args...)
}
return &cloned
}
func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
var from *tgapi.User
switch u.Type {
case tgapi.UpdateTypeMessage:
if u.Message != nil {
ctx.Msg = u.Message
}
case tgapi.UpdateTypeEditedMessage:
if u.EditedMessage != nil {
ctx.Msg = u.EditedMessage
}
case tgapi.UpdateTypeChannelPost:
if u.ChannelPost != nil {
ctx.Msg = u.ChannelPost
}
case tgapi.UpdateTypeEditedChannelPost:
if u.EditedChannelPost != nil {
ctx.Msg = u.EditedChannelPost
}
case tgapi.UpdateTypeBusinessMessage:
if u.BusinessMessage != nil {
ctx.Msg = u.BusinessMessage
}
case tgapi.UpdateTypeEditedBusinessMessage:
if u.EditedBusinessMessage != nil {
ctx.Msg = u.EditedBusinessMessage
}
case tgapi.UpdateTypeInlineQuery:
if u.InlineQuery != nil {
from = &u.InlineQuery.From
}
case tgapi.UpdateTypeChosenInlineResult:
if u.ChosenInlineResult != nil {
from = &u.ChosenInlineResult.From
}
case tgapi.UpdateTypeCallbackQuery:
if u.CallbackQuery != nil {
if u.CallbackQuery.Message != nil {
ctx.Msg = u.CallbackQuery.Message
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID
}
if u.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = u.CallbackQuery.ID
from = &u.CallbackQuery.From
}
case tgapi.UpdateTypeShippingQuery:
if u.ShippingQuery != nil {
from = &u.ShippingQuery.From
}
case tgapi.UpdateTypePreCheckoutQuery:
if u.PreCheckoutQuery != nil {
from = &u.PreCheckoutQuery.From
}
case tgapi.UpdateTypePurchasedPaidMedia:
if u.PurchasedPaidMedia != nil {
from = &u.PurchasedPaidMedia.From
}
case tgapi.UpdateTypeMyChatMember:
if u.MyChatMember != nil {
from = &u.MyChatMember.From
}
case tgapi.UpdateTypeChatMember:
if u.ChatMember != nil {
from = &u.ChatMember.From
}
case tgapi.UpdateTypeChatJoinRequest:
if u.ChatJoinRequest != nil {
from = &u.ChatJoinRequest.From
}
case tgapi.UpdateTypeBusinessConnection:
if u.BusinessConnection != nil {
from = &u.BusinessConnection.User
}
case tgapi.UpdateTypePollAnswer:
if u.PollAnswer != nil {
from = &u.PollAnswer.User
}
case tgapi.UpdateTypeMessageReaction:
if u.MessageReaction != nil {
from = u.MessageReaction.User
}
case tgapi.UpdateTypeChatBoost:
if u.ChatBoost != nil {
from = &u.ChatBoost.Boost.Source.User
}
case tgapi.UpdateTypeRemovedChatBoost:
if u.RemovedChatBoost != nil {
from = &u.RemovedChatBoost.Source.User
}
}
if ctx.Msg != nil && from == nil {
from = ctx.Msg.From
}
if from != nil {
ctx.From = from
ctx.FromID = from.ID
}
ctx.Update = *u
}
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
for _, prefix := range bot.prefixes {
if prefix == "" {
continue
}
if strings.HasPrefix(text, prefix) {
return prefix, true
}
+238
View File
@@ -0,0 +1,238 @@
package laniakea
import (
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
)
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
bot := &Bot[NoDB]{prefixes: []string{"", "/"}}
if prefix, ok := bot.checkPrefixes("hello"); ok {
t.Fatalf("unexpected prefix match for plain text: %q", prefix)
}
if prefix, ok := bot.checkPrefixes("/start"); !ok || prefix != "/" {
t.Fatalf("unexpected prefix result: prefix=%q ok=%v", prefix, ok)
}
}
func TestBotMiddlewareReceivesLogger(t *testing.T) {
logger := slog.CreateLogger()
called := false
bot := &Bot[NoDB]{
logger: logger,
middlewares: []Middleware[NoDB]{
NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool {
called = true
if ctx.Logger != logger {
t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger)
}
return true
}),
},
}
bot.handle(&tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypePoll,
Poll: &tgapi.Poll{
ID: "poll",
Question: "question",
},
})
if !called {
t.Fatal("expected bot middleware to be called")
}
}
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
plugin := NewPlugin[NoDB]("test")
handler := func(ctx *MsgContext, db NoDB) {}
for _, updateType := range []tgapi.UpdateType{
tgapi.UpdateTypeMessage,
tgapi.UpdateTypeChannelPost,
tgapi.UpdateTypeCallbackQuery,
} {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("AddUpdateHandler(%q) panicked: %v", updateType, r)
}
}()
plugin.AddUpdateHandler(updateType, handler)
}()
if _, ok := plugin.handlers[updateType]; ok {
t.Fatalf("reserved update type %q must not be registered", updateType)
}
}
}
func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
tests := []struct {
name string
update *tgapi.Update
wantID int64
}{
{
name: "inline query",
update: &tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{
ID: "iq",
From: tgapi.User{ID: 41},
Query: "ping",
},
},
wantID: 41,
},
{
name: "chosen inline result",
update: &tgapi.Update{
UpdateID: 2,
Type: tgapi.UpdateTypeChosenInlineResult,
ChosenInlineResult: &tgapi.ChosenInlineResult{
ResultID: "res",
From: tgapi.User{ID: 77},
Query: "pong",
},
},
wantID: 77,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called := false
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) {
called = true
if ctx.Update.UpdateID != tt.update.UpdateID {
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
}
if ctx.From == nil {
t.Fatal("expected ctx.From to be populated")
}
if ctx.FromID != tt.wantID {
t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantID)
}
if ctx.From.ID != tt.wantID {
t.Fatalf("unexpected ctx.From.ID: got %d want %d", ctx.From.ID, tt.wantID)
}
if ctx.Msg != nil {
t.Fatalf("did not expect message context for %s", tt.name)
}
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
}
bot.handle(tt.update)
if !called {
t.Fatalf("expected update handler for %s to be called", tt.name)
}
})
}
}
func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
firstCalled := false
secondCalled := false
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
firstCalled = true
if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
}
ctx.From = nil
ctx.FromID = 999
ctx.Text = "mutated"
ctx.Args = []string{"mutated"}
})
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) {
secondCalled = true
if ctx.From == nil {
t.Fatal("expected ctx.From to remain populated for second handler")
}
if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in second handler: got %d want 41", ctx.FromID)
}
if ctx.Text != "" {
t.Fatalf("unexpected leaked Text in second handler: %q", ctx.Text)
}
if len(ctx.Args) != 0 {
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
}
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{
clonePlugin(first),
clonePlugin(second),
},
}
bot.handle(&tgapi.Update{
UpdateID: 3,
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{
ID: "iq",
From: tgapi.User{ID: 41},
Query: "ping",
},
})
if !firstCalled || !secondCalled {
t.Fatalf("expected both handlers to be called, got first=%v second=%v", firstCalled, secondCalled)
}
}
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
called := false
plugin := NewPlugin[NoDB]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) {
called = true
if ctx.Msg == nil {
t.Fatal("expected message context")
}
if ctx.Msg.Chat == nil || ctx.Msg.Chat.ID != -1001 {
t.Fatalf("unexpected chat context: %#v", ctx.Msg.Chat)
}
if ctx.From != nil {
t.Fatalf("expected ctx.From to stay nil for sender_chat updates, got %#v", ctx.From)
}
if ctx.FromID != 0 {
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
}
}, "ping")
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
}
bot.handle(&tgapi.Update{
UpdateID: 10,
Type: tgapi.UpdateTypeChannelPost,
ChannelPost: &tgapi.Message{
MessageID: 55,
Text: "/ping",
SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
},
})
if !called {
t.Fatal("expected channel post command handler to be called")
}
}
+13 -16
View File
@@ -7,13 +7,12 @@ import (
"git.nix13.pw/scuroneko/laniakea/tgapi"
)
// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined
// Telegram keyboard button styles for visual feedback.
//
// These values map directly to Telegram Bot API's InlineKeyboardButton style field.
const (
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleDanger marks a destructive inline keyboard action.
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleSuccess marks a confirmatory inline keyboard action.
ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success"
// ButtonStylePrimary marks a primary inline keyboard action.
ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary"
)
@@ -83,8 +82,7 @@ func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) In
return b
}
// build converts the builder state into a tgapi.InlineKeyboardButton.
// This method is typically called internally by InlineKeyboard.AddButton().
// Internal helper that converts the builder state into a Telegram button.
func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{
Text: b.text,
@@ -146,8 +144,7 @@ func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
return in
}
// append adds a button to the current line. If the line is full, it auto-flushes.
// This is an internal helper used by other builder methods.
// Internal helper that appends a button and auto-flushes a full row.
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
if in.CurrentLine.Len() == in.maxRow {
in.AddLine()
@@ -235,12 +232,12 @@ type CallbackData struct {
// (int, string, bool, float64) but may not serialize complex structs meaningfully.
//
// Use this to build callback payloads for bot command routing.
func NewCallbackData(command string, args ...any) *CallbackData {
func NewCallbackData(command string, args ...any) CallbackData {
stringArgs := make([]string, len(args))
for i, arg := range args {
stringArgs[i] = fmt.Sprint(arg)
}
return &CallbackData{
return CallbackData{
Command: command,
Args: stringArgs,
}
@@ -253,8 +250,8 @@ func NewCallbackData(command string, args ...any) *CallbackData {
//
// This fallback ensures the bot receives a valid JSON payload even if internal
// errors occur — avoiding "invalid callback_data" errors from Telegram.
func (d *CallbackData) ToJson() string {
data, err := encodeJsonPayload(*d)
func (d CallbackData) ToJson() string {
data, err := encodeJsonPayload(d)
if err != nil {
// Fallback: return minimal valid JSON to avoid Telegram API rejection
return `{"cmd":""}`
@@ -264,8 +261,8 @@ func (d *CallbackData) ToJson() string {
// ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64.
// Returns an empty string if serialization or encoding fails.
func (d *CallbackData) ToBase64() string {
s, err := encodeBase64Payload(*d)
func (d CallbackData) ToBase64() string {
s, err := encodeBase64Payload(d)
if err != nil {
return ``
}
@@ -275,7 +272,7 @@ func (d *CallbackData) ToBase64() string {
// Encode serializes the CallbackData according to the specified payload type.
// Supported types: BotPayloadJson and BotPayloadBase64.
// For unknown types, returns an empty string.
func (d *CallbackData) Encode(t BotPayloadType) string {
func (d CallbackData) Encode(t BotPayloadType) string {
switch t {
case BotPayloadBase64:
return d.ToBase64()
+44
View File
@@ -0,0 +1,44 @@
package laniakea
import (
"strings"
"testing"
)
func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
kb := NewInlineKeyboardJson(2).
AddCallbackButton("A", "cmd", 1).
AddCallbackButton("B", "cmd", 2).
AddCallbackButton("C", "cmd", 3)
markup := kb.Get()
if got := len(markup.InlineKeyboard); got != 2 {
t.Fatalf("unexpected row count: %d", got)
}
if got := len(markup.InlineKeyboard[0]); got != 2 {
t.Fatalf("unexpected first row size: %d", got)
}
if got := len(markup.InlineKeyboard[1]); got != 1 {
t.Fatalf("unexpected second row size: %d", got)
}
if !strings.Contains(markup.InlineKeyboard[0][0].CallbackData, `"cmd":"cmd"`) {
t.Fatalf("expected JSON callback payload, got %q", markup.InlineKeyboard[0][0].CallbackData)
}
}
func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
kb := NewInlineKeyboardBase64(3).
AddButton(
NewInlineKbButton("Docs").
SetStyle(ButtonStylePrimary).
SetUrl("https://example.test"),
)
button := kb.Get().InlineKeyboard[0][0]
if button.Style != ButtonStylePrimary {
t.Fatalf("unexpected style: %q", button.Style)
}
if button.URL != "https://example.test" {
t.Fatalf("unexpected url: %q", button.URL)
}
}
+33 -38
View File
@@ -1,21 +1,18 @@
package laniakea
// DictEntry represents a single localized entry with language-to-text mappings.
// Example: {"ru": "Привет", "en": "Hello"}.
import "sync"
// DictEntry maps language codes to translated strings.
type DictEntry map[string]string
// L10n is a localization manager that maps keys to language-specific strings.
// L10n stores translations with a configurable fallback language and is safe for concurrent use.
type L10n struct {
entries map[string]DictEntry // Map of translation keys to language dictionaries
fallbackLang string // Language code to use when requested language is missing
mu sync.RWMutex
entries map[string]DictEntry
fallbackLang string
}
// NewL10n creates a new L10n instance with the specified fallback language.
// The fallback language is used when a requested language is not available
// for a given key.
//
// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested
// but no "ru" entry exists.
// NewL10n creates a localization store with the given fallback language.
func NewL10n(fallbackLanguage string) *L10n {
return &L10n{
entries: make(map[string]DictEntry),
@@ -23,54 +20,52 @@ func NewL10n(fallbackLanguage string) *L10n {
}
}
// AddDictEntry adds a new translation entry for the given key.
// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings.
//
// If a key already exists, it is overwritten.
//
// Returns the L10n instance for method chaining.
// AddDictEntry stores translations for key.
func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n {
l.entries[key] = value
l.mu.Lock()
defer l.mu.Unlock()
if l.entries == nil {
l.entries = make(map[string]DictEntry)
}
l.entries[key] = cloneDictEntry(value)
return l
}
// GetFallbackLanguage returns the currently configured fallback language code.
func (l *L10n) GetFallbackLanguage() string {
l.mu.RLock()
defer l.mu.RUnlock()
return l.fallbackLang
}
// Translate retrieves the translation for the given key and language.
//
// Behavior:
// - If the key exists and the language has a translation → returns the translation
// - If the key exists but the language is missing → returns the fallback language's value
// - If the key does not exist → returns the key string itself (as fallback)
//
// Example:
//
// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"})
// l.Translate("en", "greeting") → "Hello"
// l.Translate("es", "greeting") → "Hello" (fallback to "en")
// l.Translate("en", "unknown") → "unknown" (key not found)
//
// This behavior ensures that missing translations do not break UI or logs —
// instead, the original key is displayed, making it easy to identify gaps.
// Translate returns the translation for key in lang, falling back to the configured language or the key itself.
func (l *L10n) Translate(lang, key string) string {
l.mu.RLock()
defer l.mu.RUnlock()
entries, exists := l.entries[key]
if !exists {
return key // Return key as fallback when translation is missing
return key
}
// Try requested language
if translation, ok := entries[lang]; ok {
return translation
}
// Fall back to configured fallback language
if fallback, ok := entries[l.fallbackLang]; ok {
return fallback
}
// If fallback language is also missing, return the key
return key
}
func cloneDictEntry(src DictEntry) DictEntry {
if src == nil {
return nil
}
cloned := make(DictEntry, len(src))
for lang, text := range src {
cloned[lang] = text
}
return cloned
}
+77
View File
@@ -0,0 +1,77 @@
package laniakea
import (
"fmt"
"sync"
"testing"
)
func TestL10nTranslateUsesFallbackAndKey(t *testing.T) {
l10n := NewL10n("en").
AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Privet"}).
AddDictEntry("partial", DictEntry{"ru": "Tolko ru"})
tests := []struct {
name string
lang string
key string
want string
}{
{name: "exact match", lang: "ru", key: "greeting", want: "Privet"},
{name: "fallback language", lang: "es", key: "greeting", want: "Hello"},
{name: "missing fallback returns key", lang: "en", key: "partial", want: "partial"},
{name: "unknown key returns key", lang: "en", key: "unknown", want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := l10n.Translate(tt.lang, tt.key); got != tt.want {
t.Fatalf("unexpected translation: got %q want %q", got, tt.want)
}
})
}
}
func TestL10nAddDictEntryCopiesInput(t *testing.T) {
l10n := NewL10n("en")
entry := DictEntry{"en": "Hello"}
l10n.AddDictEntry("greeting", entry)
entry["en"] = "Mutated"
if got := l10n.Translate("en", "greeting"); got != "Hello" {
t.Fatalf("unexpected translation after external mutation: got %q", got)
}
}
func TestL10nZeroValueIsUsable(t *testing.T) {
var l10n L10n
l10n.AddDictEntry("greeting", DictEntry{"en": "Hello"})
if got := l10n.Translate("en", "greeting"); got != "Hello" {
t.Fatalf("unexpected translation from zero-value l10n: got %q", got)
}
}
func TestL10nConcurrentAccess(t *testing.T) {
l10n := NewL10n("en")
l10n.AddDictEntry("base", DictEntry{"en": "Hello"})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 100; j++ {
l10n.AddDictEntry(fmt.Sprintf("key-%d-%d", i, j), DictEntry{"en": "value"})
_ = l10n.Translate("en", "base")
}
}(i)
}
wg.Wait()
if got := l10n.Translate("en", "base"); got != "Hello" {
t.Fatalf("unexpected translation after concurrent access: got %q", got)
}
}
+8 -6
View File
@@ -1,6 +1,7 @@
package laniakea
import (
"context"
"encoding/json"
"git.nix13.pw/scuroneko/laniakea/tgapi"
@@ -12,7 +13,7 @@ import (
// through AllowedUpdates and includes optional request logging.
//
// Parameters:
// - None (uses bot's internal state for offset and allowed updates)
// - ctx: request context used to cancel the in-flight long polling request
//
// Returns:
// - []tgapi.Update: slice of received updates (empty if none available)
@@ -26,19 +27,20 @@ import (
// 5. Automatically updates the offset to the last received update ID + 1
// 6. Returns all received updates (empty slice if none)
//
// Note: This is a blocking call that waits up to 30 seconds for new updates.
// For non-blocking behavior, consider using webhooks instead.
// Note: This is a blocking call that waits up to 30 seconds for new updates,
// unless ctx is canceled earlier. For non-blocking behavior, consider using
// webhooks instead.
//
// Example:
//
// updates, err := bot.Updates()
// updates, err := bot.Updates(ctx)
// if err != nil {
// log.Fatal(err)
// }
// for _, update := range updates {
// // process update
// }
func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
offset := bot.GetUpdateOffset()
params := tgapi.UpdateParams{
Offset: Ptr(offset),
@@ -46,7 +48,7 @@ func (bot *Bot[T]) Updates() ([]tgapi.Update, error) {
AllowedUpdates: bot.GetUpdateTypes(),
}
updates, err := bot.api.GetUpdates(params)
updates, err := bot.api.GetUpdatesWithContext(ctx, params)
if err != nil {
return nil, err
}
+48 -40
View File
@@ -19,6 +19,10 @@ type MsgContext struct {
Msg *tgapi.Message
From *tgapi.User
// Logger is the logger assigned by the matched plugin for the current handler call.
// It may fall back to the bot logger when the plugin has no dedicated logger.
Logger *slog.Logger
InlineMsgId string
CallbackMsgId int
CallbackQueryId string
@@ -28,7 +32,6 @@ type MsgContext struct {
Args []string
errorTemplate string
botLogger *slog.Logger
l10n *L10n
draftProvider *DraftProvider
payloadType BotPayloadType
@@ -43,8 +46,7 @@ type AnswerMessage struct {
ctx *MsgContext // internal back-reference
}
// edit is an internal helper to edit a message's text with optional keyboard and parse mode.
// Used by Edit, EditMarkdown, EditCallback, etc.
// Internal helper for text edits with optional keyboard and parse mode.
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
params := tgapi.EditMessageTextP{
Text: text,
@@ -57,7 +59,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId
default:
ctx.botLogger.Errorln("Can't edit message: no valid message target")
ctx.Logger.Errorln("Can't edit message: no valid message target")
return nil
}
if keyboard != nil {
@@ -65,7 +67,7 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
}
msg, _, err := ctx.Api.EditMessageText(params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
resultMessageID := messageId
@@ -91,11 +93,10 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2)
}
// editCallback is an internal helper to edit the message associated with a callback query.
// Supports both regular callback messages and inline callback messages.
// Internal helper for editing callback-linked messages.
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
ctx.botLogger.Errorln("Can't edit non-callback update message")
ctx.Logger.Errorln("Can't edit non-callback update message")
return nil
}
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
@@ -125,8 +126,7 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2)
}
// editPhotoText edits the caption of a photo/video message.
// Returns nil when no valid edit target is available for the current context.
// Internal helper for media-caption edits.
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
params := tgapi.EditMessageCaptionP{
Caption: text,
@@ -139,7 +139,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId
default:
ctx.botLogger.Errorln("Can't edit caption: no valid message target")
ctx.Logger.Errorln("Can't edit caption: no valid message target")
return nil
}
if kb != nil {
@@ -148,7 +148,7 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
msg, _, err := ctx.Api.EditMessageCaption(params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
resultMessageID := messageId
@@ -184,11 +184,10 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2)
}
// answer sends a new message with optional keyboard and parse mode.
// Uses API limiter to respect Telegram rate limits per chat.
// Internal helper for message replies with optional keyboard and parse mode.
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't answer message without a message")
ctx.Logger.Errorln("Can't answer message without a message")
return nil
}
params := tgapi.SendMessageP{
@@ -208,7 +207,7 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
msg, err := ctx.Api.SendMessage(params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
return &AnswerMessage{
@@ -252,10 +251,10 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
return ctx.answer(text, keyboard, tgapi.ParseMDV2)
}
// answerPhoto sends a photo with optional caption and keyboard.
// Internal helper for photo replies with optional caption and keyboard.
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't answer message without a message")
ctx.Logger.Errorln("Can't answer message without a message")
return nil
}
params := tgapi.SendPhotoP{
@@ -270,10 +269,13 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
if ctx.Msg.MessageThreadID > 0 {
params.MessageThreadID = ctx.Msg.MessageThreadID
}
if ctx.Msg.DirectMessageTopic != nil {
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
}
msg, err := ctx.Api.SendPhoto(params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
return &AnswerMessage{
@@ -317,14 +319,14 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
}
// delete removes a message by ID.
// Internal helper that deletes a message by ID.
func (ctx *MsgContext) delete(messageId int) {
if messageId == 0 {
ctx.botLogger.Errorln("Can't delete message: message ID zero")
ctx.Logger.Errorln("Can't delete message: message ID zero")
return
}
if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't delete message: no chat message context")
ctx.Logger.Errorln("Can't delete message: no chat message context")
return
}
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
@@ -332,7 +334,7 @@ func (ctx *MsgContext) delete(messageId int) {
MessageID: messageId,
})
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
}
@@ -342,14 +344,13 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
// CallbackDelete deletes the message that triggered the callback query.
func (ctx *MsgContext) CallbackDelete() {
if ctx.CallbackMsgId == 0 {
ctx.botLogger.Errorln("Can't delete callback message: no callback message ID")
ctx.Logger.Errorln("Can't delete callback message: no callback message ID")
return
}
ctx.delete(ctx.CallbackMsgId)
}
// answerCallbackQuery sends a response to a callback query (optional text/alert/url).
// Does nothing if CallbackQueryId is empty.
// Internal helper that answers a callback query with optional text, alert, or URL.
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryId) == 0 {
return
@@ -359,7 +360,7 @@ func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
Text: text, ShowAlert: showAlert, URL: url,
})
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
}
@@ -378,7 +379,7 @@ func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't send action without chat message context")
ctx.Logger.Errorln("Can't send action without chat message context")
return
}
params := tgapi.SendChatActionP{
@@ -389,14 +390,11 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
}
_, err := ctx.Api.SendChatAction(params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
}
// error sends an error message to the user and logs it.
// Uses errorTemplate to format the message.
// For callbacks: sends as callback answer (no alert).
// For regular messages: sends as plain text.
// Internal helper that formats, sends, and logs an error.
func (ctx *MsgContext) error(err error) {
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
@@ -405,7 +403,7 @@ func (ctx *MsgContext) error(err error) {
} else {
ctx.answer(text, nil, tgapi.ParseNone)
}
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
// Error is an alias for error().
@@ -413,15 +411,25 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
if ctx.Msg == nil {
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil")
ctx.Logger.Errorln("can't create draft: ctx.Msg is nil")
return nil
}
if ctx.Api == nil {
ctx.Logger.Errorln("can't create draft: ctx.Api is nil")
return nil
}
if ctx.draftProvider == nil {
ctx.Logger.Errorln("can't create draft: ctx.draftProvider is nil")
return nil
}
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.botLogger.Errorln(err)
return nil
if ctx.Api.Limiter != nil {
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.Logger.Errorln(err)
return nil
}
}
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
+64
View File
@@ -0,0 +1,64 @@
package laniakea
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
)
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
},
Logger: slog.CreateLogger(),
}
answer := ctx.AnswerPhoto("photo-id", "caption")
if answer == nil {
t.Fatal("expected answer message")
}
if answer.MessageID != 9 {
t.Fatalf("unexpected message id: %d", answer.MessageID)
}
if got := gotBody["direct_messages_topic_id"]; got != float64(77) {
t.Fatalf("unexpected direct_messages_topic_id: %v", got)
}
}
+113 -39
View File
@@ -5,9 +5,12 @@ import (
"regexp"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
)
// CommandValueType defines the expected type of a command argument.
// CommandValueType defines the expected type of command argument.
type CommandValueType string
const (
@@ -23,11 +26,11 @@ const (
var (
// CommandRegexInt matches one or more digits.
CommandRegexInt = regexp.MustCompile(`\d+`)
CommandRegexInt = regexp.MustCompile(`^\d+$`)
// CommandRegexString matches any non-empty string.
CommandRegexString = regexp.MustCompile(`.+`)
CommandRegexString = regexp.MustCompile(`^.+$`)
// CommandRegexBool matches true or false.
CommandRegexBool = regexp.MustCompile(`true|false`)
CommandRegexBool = regexp.MustCompile(`^(true|false)$`)
)
// ErrCmdArgCountMismatch is returned when the number of provided arguments
@@ -49,12 +52,12 @@ type CommandArg struct {
// NewCommandArg creates a new CommandArg with the given text and type.
// Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string) *CommandArg {
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
func NewCommandArg(text string) CommandArg {
return CommandArg{CommandValueAnyType, text, CommandRegexString, false}
}
// SetValueType sets expected value type and switches built-in validation regexp.
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
regex := CommandRegexString
switch t {
case CommandValueIntType:
@@ -64,20 +67,21 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
case CommandValueAnyType:
regex = nil // Skip validation
}
c.valueType = t
c.regex = regex
return c
}
// SetRequired marks this argument as required.
// Returns the receiver for method chaining.
func (c *CommandArg) SetRequired() *CommandArg {
func (c CommandArg) SetRequired() CommandArg {
c.required = true
return c
}
// CommandExecutor is the function type that executes a command.
// It receives the message context and a database context (generic).
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T)
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T)
// Command represents a bot command with arguments, description, and executor.
// Can be registered in a Plugin and optionally skipped from auto-generation.
@@ -121,14 +125,12 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
return c
}
// validateArgs checks if the provided arguments match the command's requirements.
// Returns ErrCmdArgCountMismatch if too few arguments are provided.
// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation.
// Internal helper that validates provided command arguments.
func (c *Command[T]) validateArgs(args []string) error {
// Count required args
requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len()
if len(args) < requiredCount {
return ErrCmdArgCountMismatch
for i := range c.args.Len() {
if i >= len(args) && c.args.Get(i).required {
return ErrCmdArgCountMismatch
}
}
// Validate each argument against its regex
@@ -150,19 +152,33 @@ func (c *Command[T]) validateArgs(args []string) error {
// Plugin represents a collection of commands and payloads (e.g., callback handlers),
// with shared middleware and configuration.
//
// A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins.
// After registration, treat the plugin as committed and do not mutate it further.
// Post-registration changes through the original *Plugin are not a supported API.
type Plugin[T DbContext] struct {
name string // Name of the plugin (e.g., "admin", "user")
commands map[string]*Command[T] // Registered commands (triggered by message)
payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
logger *slog.Logger
handlers map[tgapi.UpdateType]CommandExecutor[T]
onClose func() error
}
// NewPlugin creates a new Plugin with the given name.
func NewPlugin[T DbContext](name string) *Plugin[T] {
return &Plugin[T]{
name, make(map[string]*Command[T]),
make(map[string]*Command[T]), extypes.Slice[Middleware[T]]{}, false,
name: name,
commands: make(map[string]*Command[T]),
payloads: make(map[string]*Command[T]),
middlewares: make(extypes.Slice[Middleware[T]], 0),
skipAutoCmd: false,
logger: nil,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
}
}
@@ -196,6 +212,24 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
return cmd
}
// AddUpdateHandler registers a handler for a non-command update type.
// Message, channel post, and callback query updates stay on the command/payload flow.
func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] {
switch t {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
if p.logger == nil {
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel())
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
_ = logger.Close()
return p
}
p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
return p
}
p.handlers[t] = handler
return p
}
// AddMiddleware adds a middleware to the plugin's global middleware chain.
// Middlewares are executed before any command or payload.
func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] {
@@ -209,10 +243,53 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] {
return p
}
// executeCmd finds and executes a command by its trigger string.
// Validates arguments and runs middlewares before executor.
// On error, sends an error message to the user via ctx.error().
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) {
// SetLogger sets the logger used for this plugin's handlers.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) SetLogger(l *slog.Logger) *Plugin[T] {
p.logger = l
return p
}
// RemoveLogger clears the custom logger for this plugin.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) RemoveLogger() *Plugin[T] {
p.logger = nil
return p
}
// SetOnClose registers a callback invoked from Plugin.Close after the plugin
// logger is closed.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] {
p.onClose = f
return p
}
// Close releases plugin-owned resources such as its logger and optional
// OnClose callback.
func (p *Plugin[T]) Close() error {
var e []error
if p.logger != nil {
if err := p.logger.Close(); err != nil {
e = append(e, err)
}
}
if p.onClose != nil {
if err := p.onClose(); err != nil {
e = append(e, err)
}
}
return errors.Join(e...)
}
// Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) {
command, exists := p.commands[cmd]
if !exists {
ctx.error(errors.New("command not found"))
@@ -226,19 +303,17 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) {
// Run command-specific middlewares
for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) {
if !m.Execute(ctx, db) {
return
}
}
// Execute command
command.exec(ctx, dbContext)
command.exec(ctx, db)
}
// executePayload finds and executes a payload by its callback_data string.
// Validates arguments and runs middlewares before executor.
// On error, sends an error message to the user via ctx.error().
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) {
// Internal helper that validates and executes a payload handler.
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) {
command, exists := p.payloads[payload]
if !exists {
ctx.error(errors.New("payload not found"))
@@ -252,18 +327,17 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T
// Run command-specific middlewares
for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) {
if !m.Execute(ctx, db) {
return
}
}
// Execute payload
command.exec(ctx, dbContext)
command.exec(ctx, db)
}
// executeMiddlewares runs all plugin middlewares in order.
// Returns false if any middleware returns false (blocks execution).
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
// Internal helper that runs plugin middlewares in order.
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
for _, m := range p.middlewares {
if !m.Execute(ctx, db) {
return false
@@ -275,7 +349,7 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
// MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it.
// If async, return value is ignored.
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool
// Middleware represents a reusable execution interceptor.
// Can be synchronous (blocking) or asynchronous (non-blocking).
@@ -287,19 +361,19 @@ type Middleware[T DbContext] struct {
}
// NewMiddleware creates a new synchronous middleware.
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] {
return &Middleware[T]{name, executor, 0, false}
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] {
return Middleware[T]{name, executor, 0, false}
}
// SetOrder sets the execution order (currently ignored).
func (m *Middleware[T]) SetOrder(order int) *Middleware[T] {
func (m Middleware[T]) SetOrder(order int) Middleware[T] {
m.order = order
return m
}
// SetAsync marks the middleware to run asynchronously.
// Execution continues regardless of its return value.
func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
func (m Middleware[T]) SetAsync(async bool) Middleware[T] {
m.async = async
return m
}
@@ -307,7 +381,7 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
// Execute runs the middleware.
// If async, runs in a goroutine and returns true immediately.
// Otherwise, returns the result of the executor.
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool {
func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool {
if m.async {
ctx := *ctx // copy context to avoid race condition
go func(ctx MsgContext) {
+40
View File
@@ -0,0 +1,40 @@
package laniakea
import (
"errors"
"testing"
)
func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
if err := intCmd.validateArgs([]string{"123"}); err != nil {
t.Fatalf("expected valid integer argument, got %v", err)
}
if err := intCmd.validateArgs([]string{"123abc"}); !errors.Is(err, ErrCmdArgRegexpMismatch) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
}
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) {}, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
if err := boolCmd.validateArgs([]string{"false"}); err != nil {
t.Fatalf("expected valid bool argument, got %v", err)
}
if err := boolCmd.validateArgs([]string{"falsey"}); !errors.Is(err, ErrCmdArgRegexpMismatch) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err)
}
}
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand[NoDB](
func(ctx *MsgContext, db NoDB) {},
"mixed",
NewCommandArg("optional"),
NewCommandArg("required").SetRequired(),
)
if err := cmd.validateArgs([]string{"only-optional"}); !errors.Is(err, ErrCmdArgCountMismatch) {
t.Fatalf("expected ErrCmdArgCountMismatch when required second arg is missing, got %v", err)
}
if err := cmd.validateArgs([]string{"optional", "required"}); err != nil {
t.Fatalf("expected both args to validate, got %v", err)
}
}
+5 -5
View File
@@ -33,8 +33,8 @@ type Runner[T DbContext] struct {
//
// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute().
func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] {
return &Runner[T]{
func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] {
return Runner[T]{
name: name,
fn: fn,
async: true, // Default: run asynchronously
@@ -45,7 +45,7 @@ func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] {
// Onetime sets whether the runner executes once or repeatedly.
// If true, the runner runs only once.
// If false, the runner runs in a loop with the configured timeout.
func (r *Runner[T]) Onetime(onetime bool) *Runner[T] {
func (r Runner[T]) Onetime(onetime bool) Runner[T] {
r.onetime = onetime
return r
}
@@ -55,7 +55,7 @@ func (r *Runner[T]) Onetime(onetime bool) *Runner[T] {
// If false, the runner blocks the caller during execution.
//
// Note: If onetime=false and async=false, the runner will be skipped with a warning.
func (r *Runner[T]) Async(async bool) *Runner[T] {
func (r Runner[T]) Async(async bool) Runner[T] {
r.async = async
return r
}
@@ -69,7 +69,7 @@ func (r *Runner[T]) Async(async bool) *Runner[T] {
//
// A zero value (time.Duration(0)) is allowed but may trigger a warning
// if used with a background (non-onetime) async runner.
func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] {
r.timeout = timeout
return r
}
+62
View File
@@ -0,0 +1,62 @@
package laniakea
import (
"context"
"sync/atomic"
"testing"
"time"
"git.nix13.pw/scuroneko/slog"
)
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
runners: []Runner[NoDB]{
NewRunner("sync-once", func(*Bot[NoDB]) error {
calls.Add(1)
return nil
}).Onetime(true).Async(false),
},
}
bot.ExecRunners(context.Background())
if got := calls.Load(); got != 1 {
t.Fatalf("unexpected sync runner call count: %d", got)
}
}
func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
var calls atomic.Int32
triggered := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
runners: []Runner[NoDB]{
NewRunner("background", func(*Bot[NoDB]) error {
if calls.Add(1) == 1 {
triggered <- struct{}{}
}
return nil
}).Timeout(5 * time.Millisecond),
},
}
bot.ExecRunners(ctx)
select {
case <-triggered:
case <-time.After(time.Second):
t.Fatal("background runner did not execute")
}
cancel()
bot.runnerBgWG.Wait()
if calls.Load() == 0 {
t.Fatal("expected background runner to be called at least once")
}
}
+16 -25
View File
@@ -95,10 +95,9 @@ type API struct {
}
// NewAPI creates a new API client from options.
// Always call CloseApi() when done to release resources.
// Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API {
l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API")
l.AddWriter(l.CreateJsonStdoutWriter())
l := utils.CreateLogger("API", utils.GetLoggerLevel())
client := opts.client
if client == nil {
@@ -120,11 +119,14 @@ func NewAPI(opts *APIOpts) *API {
}
}
// CloseApi shuts down the internal worker pool and closes the logger.
// Close shuts down the internal worker pool and closes the logger.
// Must be called to avoid resource leaks.
// See https://core.telegram.org/bots/api
func (api *API) CloseApi() error {
func (api *API) Close() error {
api.pool.stop()
if api.client != nil {
api.client.CloseIdleConnections()
}
return api.logger.Close()
}
@@ -150,37 +152,29 @@ type ApiResponse[R any] struct {
Parameters *ResponseParameters `json:"parameters,omitempty"`
}
// TelegramRequest is an internal helper struct.
// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged.
// Instead, use explicit methods like SendMessage, GetUpdates, etc.
// TelegramRequest is a low-level Telegram API request wrapper.
//
// Why? Because using generics with arbitrary types P and R leads to:
// - No compile-time validation of parameters
// - No IDE autocompletion
// - Runtime panics on malformed JSON
// - Hard-to-debug errors
//
// Recommended: Define specific methods for each Telegram method (see below).
// Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest
// bypasses method-specific parameter types and convenience helpers, so callers are
// responsible for using the correct method name and compatible request and response types.
// In that sense it is an unsafe escape hatch compared with the typed API surface.
type TelegramRequest[R, P any] struct {
method string
params P
chatId int64
}
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID.
// NewRequest creates a low-level TelegramRequest with no associated chat ID.
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, 0}
}
// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID.
// NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
// The chat ID is used for per-chat rate limiting.
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, chatId}
}
// doRequest performs a single HTTP request to Telegram API.
// Handles rate limiting, retries on 429, and parses responses.
// Must be called within a worker pool context if using DoWithContext.
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
var zero R
reqData, err := json.Marshal(r.params)
@@ -202,7 +196,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
req.Header.Set("Accept-Encoding", "gzip")
for {
// Apply rate limiting before making the request
@@ -298,15 +291,13 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
return r.DoWithContext(context.Background(), api)
}
// readBody reads and limits response body to prevent memory exhaustion.
// Telegram responses are typically small (<1MB), but we cap at 10MB.
// Internal helper that reads and caps a Telegram response body.
func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20) // 10 MB
return io.ReadAll(reader)
}
// parseBody unmarshals a Telegram API response into a typed ApiResponse.
// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle.
// Internal helper that parses a typed Telegram API response body.
func parseBody[R any](data []byte) (ApiResponse[R], error) {
var resp ApiResponse[R]
err := json.Unmarshal(data, &resp)
+90
View File
@@ -0,0 +1,90 @@
package tgapi
import (
"io"
"net/http"
"strings"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}
type closingTransport struct {
roundTripFunc
closed bool
}
func (t *closingTransport) CloseIdleConnections() {
t.closed = true
}
func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
var gotPath string
var gotAcceptEncoding string
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
gotAcceptEncoding = req.Header.Get("Accept-Encoding")
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
user, err := api.GetMe()
if err != nil {
t.Fatalf("GetMe returned error: %v", err)
}
if user.FirstName != "Test" {
t.Fatalf("unexpected first name: %q", user.FirstName)
}
if gotPath != "/bottoken/getMe" {
t.Fatalf("unexpected request path: %s", gotPath)
}
if gotAcceptEncoding != "" {
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
}
}
func TestAPICloseClosesIdleConnections(t *testing.T) {
transport := &closingTransport{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)),
}, nil
},
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(&http.Client{Transport: transport}),
)
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if !transport.closed {
t.Fatal("expected Close to close idle HTTP connections")
}
}
+90 -10
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SendPhotoP holds parameters for the sendPhoto method.
// See https://core.telegram.org/bots/api#sendphoto
type SendPhotoP struct {
@@ -32,6 +34,14 @@ func (api *API) SendPhoto(params SendPhotoP) (Message, error) {
return req.Do(api)
}
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendphoto
func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhotoP) (Message, error) {
req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAudioP holds parameters for the sendAudio method.
// See https://core.telegram.org/bots/api#sendaudio
type SendAudioP struct {
@@ -47,6 +57,7 @@ type SendAudioP struct {
Duration int `json:"duration,omitempty"`
Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
@@ -65,6 +76,14 @@ func (api *API) SendAudio(params SendAudioP) (Message, error) {
return req.Do(api)
}
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendaudio
func (api *API) SendAudioWithContext(ctx context.Context, params SendAudioP) (Message, error) {
req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDocumentP holds parameters for the sendDocument method.
// See https://core.telegram.org/bots/api#senddocument
type SendDocumentP struct {
@@ -73,10 +92,12 @@ type SendDocumentP struct {
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Document string `json:"document"`
Caption string `json:"caption,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
Document string `json:"document"`
Thumbnail string `json:"thumbnail,omitempty"`
Caption string `json:"caption,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
@@ -95,6 +116,14 @@ func (api *API) SendDocument(params SendDocumentP) (Message, error) {
return req.Do(api)
}
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddocument
func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocumentP) (Message, error) {
req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoP holds parameters for the sendVideo method.
// See https://core.telegram.org/bots/api#sendvideo
type SendVideoP struct {
@@ -103,11 +132,12 @@ type SendVideoP struct {
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Video string `json:"video"`
Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Cover string `json:"cover,omitempty"`
Video string `json:"video"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Cover string `json:"cover,omitempty"`
StartTimestamp int `json:"start_timestamp,omitempty"`
Caption string `json:"caption,omitempty"`
@@ -134,6 +164,14 @@ func (api *API) SendVideo(params SendVideoP) (Message, error) {
return req.Do(api)
}
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideo
func (api *API) SendVideoWithContext(ctx context.Context, params SendVideoP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAnimationP holds parameters for the sendAnimation method.
// See https://core.telegram.org/bots/api#sendanimation
type SendAnimationP struct {
@@ -143,6 +181,7 @@ type SendAnimationP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Animation string `json:"animation"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
@@ -169,6 +208,14 @@ func (api *API) SendAnimation(params SendAnimationP) (Message, error) {
return req.Do(api)
}
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendanimation
func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimationP) (Message, error) {
req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVoiceP holds parameters for the sendVoice method.
// See https://core.telegram.org/bots/api#sendvoice
type SendVoiceP struct {
@@ -194,11 +241,19 @@ type SendVoiceP struct {
// SendVoice sends a voice note.
// See https://core.telegram.org/bots/api#sendvoice
func (api *API) SendVoice(params *SendVoiceP) (Message, error) {
func (api *API) SendVoice(params SendVoiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.Do(api)
}
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvoice
func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoNoteP holds parameters for the sendVideoNote method.
// See https://core.telegram.org/bots/api#sendvideonote
type SendVideoNoteP struct {
@@ -208,6 +263,7 @@ type SendVideoNoteP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
VideoNote string `json:"video_note"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"`
Length int `json:"length,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"`
@@ -227,6 +283,14 @@ func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) {
return req.Do(api)
}
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideonote
func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNoteP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPaidMediaP holds parameters for the sendPaidMedia method.
// See https://core.telegram.org/bots/api#sendpaidmedia
type SendPaidMediaP struct {
@@ -258,6 +322,14 @@ func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) {
return req.Do(api)
}
// SendPaidMediaWithContext is the context-aware variant of SendPaidMedia.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendpaidmedia
func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMediaP) (Message, error) {
req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMediaGroupP holds parameters for the sendMediaGroup method.
// See https://core.telegram.org/bots/api#sendmediagroup
type SendMediaGroupP struct {
@@ -280,3 +352,11 @@ func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.Do(api)
}
// SendMediaGroupWithContext is the context-aware variant of SendMediaGroup.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmediagroup
func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroupP) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+6 -6
View File
@@ -55,12 +55,12 @@ type InputPaidMedia struct {
Type InputPaidMediaType `json:"type"`
Media string `json:"media"`
Cover string `json:"cover"`
StartTimestamp int64 `json:"start_timestamp"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
SupportsStreaming bool `json:"supports_streaming"`
Cover *string `json:"cover,omitempty"`
StartTimestamp *int64 `json:"start_timestamp,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
Duration *int `json:"duration,omitempty"`
SupportsStreaming *bool `json:"supports_streaming,omitempty"`
}
// PhotoSize represents one size of a photo or a file/sticker thumbnail.
+150 -4
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SetMyCommandsP holds parameters for the setMyCommands method.
// See https://core.telegram.org/bots/api#setmycommands
type SetMyCommandsP struct {
@@ -16,6 +18,14 @@ func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) {
return req.Do(api)
}
// SetMyCommandsWithContext is the context-aware variant of SetMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmycommands
func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommandsP) (bool, error) {
req := NewRequest[bool]("setMyCommands", params)
return req.DoWithContext(ctx, api)
}
// DeleteMyCommandsP holds parameters for the deleteMyCommands method.
// See https://core.telegram.org/bots/api#deletemycommands
type DeleteMyCommandsP struct {
@@ -31,6 +41,14 @@ func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) {
return req.Do(api)
}
// DeleteMyCommandsWithContext is the context-aware variant of DeleteMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemycommands
func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommandsP) (bool, error) {
req := NewRequest[bool]("deleteMyCommands", params)
return req.DoWithContext(ctx, api)
}
// GetMyCommands holds parameters for the getMyCommands method.
// See https://core.telegram.org/bots/api#getmycommands
type GetMyCommands struct {
@@ -45,6 +63,14 @@ func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) {
return req.Do(api)
}
// GetMyCommandsWithContext is the context-aware variant of GetMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmycommands
func (api *API) GetMyCommandsWithContext(ctx context.Context, params GetMyCommands) ([]BotCommand, error) {
req := NewRequest[[]BotCommand]("getMyCommands", params)
return req.DoWithContext(ctx, api)
}
// SetMyName holds parameters for the setMyName method.
// See https://core.telegram.org/bots/api#setmyname
type SetMyName struct {
@@ -60,6 +86,14 @@ func (api *API) SetMyName(params SetMyName) (bool, error) {
return req.Do(api)
}
// SetMyNameWithContext is the context-aware variant of SetMyName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyname
func (api *API) SetMyNameWithContext(ctx context.Context, params SetMyName) (bool, error) {
req := NewRequest[bool]("setMyName", params)
return req.DoWithContext(ctx, api)
}
// GetMyName holds parameters for the getMyName method.
// See https://core.telegram.org/bots/api#getmyname
type GetMyName struct {
@@ -73,6 +107,14 @@ func (api *API) GetMyName(params GetMyName) (BotName, error) {
return req.Do(api)
}
// GetMyNameWithContext is the context-aware variant of GetMyName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmyname
func (api *API) GetMyNameWithContext(ctx context.Context, params GetMyName) (BotName, error) {
req := NewRequest[BotName]("getMyName", params)
return req.DoWithContext(ctx, api)
}
// SetMyDescription holds parameters for the setMyDescription method.
// See https://core.telegram.org/bots/api#setmydescription
type SetMyDescription struct {
@@ -88,6 +130,14 @@ func (api *API) SetMyDescription(params SetMyDescription) (bool, error) {
return req.Do(api)
}
// SetMyDescriptionWithContext is the context-aware variant of SetMyDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmydescription
func (api *API) SetMyDescriptionWithContext(ctx context.Context, params SetMyDescription) (bool, error) {
req := NewRequest[bool]("setMyDescription", params)
return req.DoWithContext(ctx, api)
}
// GetMyDescription holds parameters for the getMyDescription method.
// See https://core.telegram.org/bots/api#getmydescription
type GetMyDescription struct {
@@ -101,6 +151,14 @@ func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error
return req.Do(api)
}
// GetMyDescriptionWithContext is the context-aware variant of GetMyDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmydescription
func (api *API) GetMyDescriptionWithContext(ctx context.Context, params GetMyDescription) (BotDescription, error) {
req := NewRequest[BotDescription]("getMyDescription", params)
return req.DoWithContext(ctx, api)
}
// SetMyShortDescription holds parameters for the setMyShortDescription method.
// See https://core.telegram.org/bots/api#setmyshortdescription
type SetMyShortDescription struct {
@@ -116,6 +174,14 @@ func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error
return req.Do(api)
}
// SetMyShortDescriptionWithContext is the context-aware variant of SetMyShortDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyshortdescription
func (api *API) SetMyShortDescriptionWithContext(ctx context.Context, params SetMyShortDescription) (bool, error) {
req := NewRequest[bool]("setMyShortDescription", params)
return req.DoWithContext(ctx, api)
}
// GetMyShortDescription holds parameters for the getMyShortDescription method.
// See https://core.telegram.org/bots/api#getmyshortdescription
type GetMyShortDescription struct {
@@ -129,6 +195,14 @@ func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDes
return req.Do(api)
}
// GetMyShortDescriptionWithContext is the context-aware variant of GetMyShortDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmyshortdescription
func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params GetMyShortDescription) (BotShortDescription, error) {
req := NewRequest[BotShortDescription]("getMyShortDescription", params)
return req.DoWithContext(ctx, api)
}
// SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method.
// See https://core.telegram.org/bots/api#setmyprofilephoto
type SetMyProfilePhotoP struct {
@@ -143,6 +217,14 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) {
return req.Do(api)
}
// SetMyProfilePhotoWithContext is the context-aware variant of SetMyProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyprofilephoto
func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhotoP) (bool, error) {
req := NewRequest[bool]("setMyProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// RemoveMyProfilePhoto removes the bot's profile photo.
// Returns true on success.
// See https://core.telegram.org/bots/api#removemyprofilephoto
@@ -151,10 +233,18 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) {
return req.Do(api)
}
// RemoveMyProfilePhotoWithContext is the context-aware variant of RemoveMyProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removemyprofilephoto
func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool]("removeMyProfilePhoto", NoParams)
return req.DoWithContext(ctx, api)
}
// SetChatMenuButtonP holds parameters for the setChatMenuButton method.
// See https://core.telegram.org/bots/api#setchatmenubutton
type SetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"`
ChatID int64 `json:"chat_id,omitempty"`
MenuButton MenuButtonType `json:"menu_button"`
}
@@ -166,19 +256,35 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) {
return req.Do(api)
}
// SetChatMenuButtonWithContext is the context-aware variant of SetChatMenuButton.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatmenubutton
func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButtonP) (bool, error) {
req := NewRequest[bool]("setChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// GetChatMenuButtonP holds parameters for the getChatMenuButton method.
// See https://core.telegram.org/bots/api#getchatmenubutton
type GetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"`
ChatID int64 `json:"chat_id,omitempty"`
}
// GetChatMenuButton returns the current menu button for the given chat.
// See https://core.telegram.org/bots/api#getchatmenubutton
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) {
req := NewRequest[BaseMenuButton]("getChatMenuButton", params)
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) {
req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.Do(api)
}
// GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmenubutton
func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (MenuButton, error) {
req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights
type SetMyDefaultAdministratorRightsP struct {
@@ -194,6 +300,14 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator
return req.Do(api)
}
// SetMyDefaultAdministratorRightsWithContext is the context-aware variant of SetMyDefaultAdministratorRights.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights
func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRightsP) (bool, error) {
req := NewRequest[bool]("setMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
type GetMyDefaultAdministratorRightsP struct {
@@ -207,6 +321,14 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator
return req.Do(api)
}
// GetMyDefaultAdministratorRightsWithContext is the context-aware variant of GetMyDefaultAdministratorRights.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) {
req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetAvailableGifts returns the list of gifts that can be sent by the bot.
// See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGifts() (Gifts, error) {
@@ -214,6 +336,14 @@ func (api *API) GetAvailableGifts() (Gifts, error) {
return req.Do(api)
}
// GetAvailableGiftsWithContext is the context-aware variant of GetAvailableGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) {
req := NewRequest[Gifts]("getAvailableGifts", NoParams)
return req.DoWithContext(ctx, api)
}
// SendGiftP holds parameters for the sendGift method.
// See https://core.telegram.org/bots/api#sendgift
type SendGiftP struct {
@@ -234,6 +364,14 @@ func (api *API) SendGift(params SendGiftP) (bool, error) {
return req.Do(api)
}
// SendGiftWithContext is the context-aware variant of SendGift.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendgift
func (api *API) SendGiftWithContext(ctx context.Context, params SendGiftP) (bool, error) {
req := NewRequest[bool]("sendGift", params)
return req.DoWithContext(ctx, api)
}
// GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
type GiftPremiumSubscriptionP struct {
@@ -252,3 +390,11 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool,
req := NewRequest[bool]("giftPremiumSubscription", params)
return req.Do(api)
}
// GiftPremiumSubscriptionWithContext is the context-aware variant of GiftPremiumSubscription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscriptionP) (bool, error) {
req := NewRequest[bool]("giftPremiumSubscription", params)
return req.DoWithContext(ctx, api)
}
+12 -7
View File
@@ -54,7 +54,9 @@ type BotShortDescription struct {
type InputProfilePhotoType string
const (
InputProfilePhotoStaticType InputProfilePhotoType = "static"
// InputProfilePhotoStaticType identifies a static profile photo input.
InputProfilePhotoStaticType InputProfilePhotoType = "static"
// InputProfilePhotoAnimatedType identifies an animated profile photo input.
InputProfilePhotoAnimatedType InputProfilePhotoType = "animated"
)
@@ -75,17 +77,20 @@ type InputProfilePhoto struct {
type MenuButtonType string
const (
// MenuButtonCommandsType identifies a commands menu button.
MenuButtonCommandsType MenuButtonType = "commands"
MenuButtonWebAppType MenuButtonType = "web_app"
MenuButtonDefaultType MenuButtonType = "default"
// MenuButtonWebAppType identifies a web app menu button.
MenuButtonWebAppType MenuButtonType = "web_app"
// MenuButtonDefaultType identifies Telegram's default menu button.
MenuButtonDefaultType MenuButtonType = "default"
)
// BaseMenuButton represents a menu button.
// MenuButton represents a menu button.
// See https://core.telegram.org/bots/api#menubutton
type BaseMenuButton struct {
type MenuButton struct {
Type MenuButtonType `json:"type"`
// WebApp fields (for web_app button)
Text string `json:"text"`
WebApp WebAppInfo `json:"web_app"`
Text *string `json:"text"`
WebApp *WebAppInfo `json:"web_app"`
}
+194
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// VerifyUserP holds parameters for the verifyUser method.
// See https://core.telegram.org/bots/api#verifyuser
type VerifyUserP struct {
@@ -15,6 +17,14 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) {
return req.Do(api)
}
// VerifyUserWithContext is the context-aware variant of VerifyUser.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#verifyuser
func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUserP) (bool, error) {
req := NewRequest[bool]("verifyUser", params)
return req.DoWithContext(ctx, api)
}
// VerifyChatP holds parameters for the verifyChat method.
// See https://core.telegram.org/bots/api#verifychat
type VerifyChatP struct {
@@ -30,6 +40,14 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) {
return req.Do(api)
}
// VerifyChatWithContext is the context-aware variant of VerifyChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#verifychat
func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChatP) (bool, error) {
req := NewRequest[bool]("verifyChat", params)
return req.DoWithContext(ctx, api)
}
// RemoveUserVerificationP holds parameters for the removeUserVerification method.
// See https://core.telegram.org/bots/api#removeuserverification
type RemoveUserVerificationP struct {
@@ -44,6 +62,14 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er
return req.Do(api)
}
// RemoveUserVerificationWithContext is the context-aware variant of RemoveUserVerification.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removeuserverification
func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerificationP) (bool, error) {
req := NewRequest[bool]("removeUserVerification", params)
return req.DoWithContext(ctx, api)
}
// RemoveChatVerificationP holds parameters for the removeChatVerification method.
// See https://core.telegram.org/bots/api#removechatverification
type RemoveChatVerificationP struct {
@@ -58,6 +84,14 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er
return req.Do(api)
}
// RemoveChatVerificationWithContext is the context-aware variant of RemoveChatVerification.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removechatverification
func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerificationP) (bool, error) {
req := NewRequest[bool]("removeChatVerification", params)
return req.DoWithContext(ctx, api)
}
// ReadBusinessMessageP holds parameters for the readBusinessMessage method.
// See https://core.telegram.org/bots/api#readbusinessmessage
type ReadBusinessMessageP struct {
@@ -74,6 +108,14 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) {
return req.Do(api)
}
// ReadBusinessMessageWithContext is the context-aware variant of ReadBusinessMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#readbusinessmessage
func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessageP) (bool, error) {
req := NewRequest[bool]("readBusinessMessage", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessConnectionP holds parameters for the getBusinessConnection method.
// See https://core.telegram.org/bots/api#getbusinessconnection
type GetBusinessConnectionP struct {
@@ -87,6 +129,14 @@ func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessCo
return req.Do(api)
}
// GetBusinessConnectionWithContext is the context-aware variant of GetBusinessConnection.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getbusinessconnection
func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnectionP) (BusinessConnection, error) {
req := NewRequest[BusinessConnection]("getBusinessConnection", params)
return req.DoWithContext(ctx, api)
}
// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method.
// See https://core.telegram.org/bots/api#deletebusinessmessages
type DeleteBusinessMessagesP struct {
@@ -102,6 +152,14 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, er
return req.Do(api)
}
// DeleteBusinessMessagesWithContext is the context-aware variant of DeleteBusinessMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletebusinessmessages
func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessagesP) (bool, error) {
req := NewRequest[bool]("deleteBusinessMessages", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountNameP holds parameters for the setBusinessAccountName method.
// See https://core.telegram.org/bots/api#setbusinessaccountname
type SetBusinessAccountNameP struct {
@@ -118,6 +176,14 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, er
return req.Do(api)
}
// SetBusinessAccountNameWithContext is the context-aware variant of SetBusinessAccountName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountname
func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountNameP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountName", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method.
// See https://core.telegram.org/bots/api#setbusinessaccountusername
type SetBusinessAccountUsernameP struct {
@@ -133,6 +199,14 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (
return req.Do(api)
}
// SetBusinessAccountUsernameWithContext is the context-aware variant of SetBusinessAccountUsername.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountusername
func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsernameP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountUsername", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
type SetBusinessAccountBioP struct {
@@ -148,6 +222,14 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, erro
return req.Do(api)
}
// SetBusinessAccountBioWithContext is the context-aware variant of SetBusinessAccountBio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBioP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountBio", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
type SetBusinessAccountProfilePhoto struct {
@@ -164,6 +246,14 @@ func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfileP
return req.Do(api)
}
// SetBusinessAccountProfilePhotoWithContext is the context-aware variant of SetBusinessAccountProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, params SetBusinessAccountProfilePhoto) (bool, error) {
req := NewRequest[bool]("setBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
type RemoveBusinessAccountProfilePhotoP struct {
@@ -179,6 +269,14 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr
return req.Do(api)
}
// RemoveBusinessAccountProfilePhotoWithContext is the context-aware variant of RemoveBusinessAccountProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhotoP) (bool, error) {
req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
type SetBusinessAccountGiftSettingsP struct {
@@ -195,6 +293,14 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett
return req.Do(api)
}
// SetBusinessAccountGiftSettingsWithContext is the context-aware variant of SetBusinessAccountGiftSettings.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettingsP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountGiftSettings", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
type GetBusinessAccountStarBalanceP struct {
@@ -208,6 +314,14 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan
return req.Do(api)
}
// GetBusinessAccountStarBalanceWithContext is the context-aware variant of GetBusinessAccountStarBalance.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalanceP) (StarAmount, error) {
req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params)
return req.DoWithContext(ctx, api)
}
// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars
type TransferBusinessAccountStarsP struct {
@@ -223,6 +337,14 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars
return req.Do(api)
}
// TransferBusinessAccountStarsWithContext is the context-aware variant of TransferBusinessAccountStars.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars
func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStarsP) (bool, error) {
req := NewRequest[bool]("transferBusinessAccountStars", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts
type GetBusinessAccountGiftsP struct {
@@ -246,6 +368,14 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedG
return req.Do(api)
}
// GetBusinessAccountGiftsWithContext is the context-aware variant of GetBusinessAccountGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts
func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params)
return req.DoWithContext(ctx, api)
}
// ConvertGiftToStarsP holds parameters for the convertGiftToStars method.
// See https://core.telegram.org/bots/api#convertgifttostars
type ConvertGiftToStarsP struct {
@@ -261,6 +391,14 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) {
return req.Do(api)
}
// ConvertGiftToStarsWithContext is the context-aware variant of ConvertGiftToStars.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#convertgifttostars
func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStarsP) (bool, error) {
req := NewRequest[bool]("convertGiftToStars", params)
return req.DoWithContext(ctx, api)
}
// UpgradeGiftP holds parameters for the upgradeGift method.
// See https://core.telegram.org/bots/api#upgradegift
type UpgradeGiftP struct {
@@ -278,6 +416,14 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) {
return req.Do(api)
}
// UpgradeGiftWithContext is the context-aware variant of UpgradeGift.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#upgradegift
func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGiftP) (bool, error) {
req := NewRequest[bool]("upgradeGift", params)
return req.DoWithContext(ctx, api)
}
// TransferGiftP holds parameters for the transferGift method.
// See https://core.telegram.org/bots/api#transfergift
type TransferGiftP struct {
@@ -295,6 +441,14 @@ func (api *API) TransferGift(params TransferGiftP) (bool, error) {
return req.Do(api)
}
// TransferGiftWithContext is the context-aware variant of TransferGift.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#transfergift
func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGiftP) (bool, error) {
req := NewRequest[bool]("transferGift", params)
return req.DoWithContext(ctx, api)
}
// PostStoryP holds parameters for the postStory method.
// See https://core.telegram.org/bots/api#poststory
type PostStoryP struct {
@@ -318,6 +472,14 @@ func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) {
return req.Do(api)
}
// PostStoryPhotoWithContext is the context-aware variant of PostStoryPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryPhotoWithContext(ctx context.Context, params PostStoryP) (Story, error) {
req := NewRequest[Story]("postStory", params)
return req.DoWithContext(ctx, api)
}
// PostStoryVideo posts a story with a video.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryVideo(params PostStoryP) (Story, error) {
@@ -325,6 +487,14 @@ func (api *API) PostStoryVideo(params PostStoryP) (Story, error) {
return req.Do(api)
}
// PostStoryVideoWithContext is the context-aware variant of PostStoryVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryVideoWithContext(ctx context.Context, params PostStoryP) (Story, error) {
req := NewRequest[Story]("postStory", params)
return req.DoWithContext(ctx, api)
}
// RepostStoryP holds parameters for the repostStory method.
// See https://core.telegram.org/bots/api#repoststory
type RepostStoryP struct {
@@ -344,6 +514,14 @@ func (api *API) RepostStory(params RepostStoryP) (Story, error) {
return req.Do(api)
}
// RepostStoryWithContext is the context-aware variant of RepostStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#repoststory
func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStoryP) (Story, error) {
req := NewRequest[Story]("repostStory", params)
return req.DoWithContext(ctx, api)
}
// EditStoryP holds parameters for the editStory method.
// See https://core.telegram.org/bots/api#editstory
type EditStoryP struct {
@@ -365,6 +543,14 @@ func (api *API) EditStory(params EditStoryP) (Story, error) {
return req.Do(api)
}
// EditStoryWithContext is the context-aware variant of EditStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editstory
func (api *API) EditStoryWithContext(ctx context.Context, params EditStoryP) (Story, error) {
req := NewRequest[Story]("editStory", params)
return req.DoWithContext(ctx, api)
}
// DeleteStoryP holds parameters for the deleteStory method.
// See https://core.telegram.org/bots/api#deletestory
type DeleteStoryP struct {
@@ -379,3 +565,11 @@ func (api *API) DeleteStory(params DeleteStoryP) (bool, error) {
req := NewRequest[bool]("deleteStory", params)
return req.Do(api)
}
// DeleteStoryWithContext is the context-aware variant of DeleteStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestory
func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStoryP) (bool, error) {
req := NewRequest[bool]("deleteStory", params)
return req.DoWithContext(ctx, api)
}
+19 -4
View File
@@ -60,11 +60,21 @@ type BusinessConnection struct {
IsEnabled bool `json:"is_enabled"`
}
// BusinessMessagesDeleted is received when messages are deleted from a connected business account.
// See https://core.telegram.org/bots/api#businessmessagesdeleted
type BusinessMessagesDeleted struct {
BusinessConnectionID string `json:"business_connection_id"`
Chat Chat `json:"chat"`
MessageIDs []int `json:"message_ids"`
}
// InputStoryContentType indicates the type of input story content.
type InputStoryContentType string
const (
// InputStoryContentPhotoType identifies photo story content.
InputStoryContentPhotoType InputStoryContentType = "photo"
// InputStoryContentVideoType identifies video story content.
InputStoryContentVideoType InputStoryContentType = "video"
)
@@ -98,10 +108,15 @@ type StoryAreaPosition struct {
type StoryAreaTypeType string
const (
StoryAreaTypeLocationType StoryAreaTypeType = "location"
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
StoryAreaTypeLinkType StoryAreaTypeType = "link"
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
// StoryAreaTypeLocationType identifies a location story area.
StoryAreaTypeLocationType StoryAreaTypeType = "location"
// StoryAreaTypeReactionType identifies a suggested reaction story area.
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
// StoryAreaTypeLinkType identifies a link story area.
StoryAreaTypeLinkType StoryAreaTypeType = "link"
// StoryAreaTypeWeatherType identifies a weather story area.
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
// StoryAreaTypeUniqueGiftType identifies a unique gift story area.
StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift"
)
+258
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// BanChatMemberP holds parameters for the banChatMember method.
// See https://core.telegram.org/bots/api#banchatmember
type BanChatMemberP struct {
@@ -17,6 +19,14 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) {
return req.Do(api)
}
// BanChatMemberWithContext is the context-aware variant of BanChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#banchatmember
func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMemberP) (bool, error) {
req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatMemberP holds parameters for the unbanChatMember method.
// See https://core.telegram.org/bots/api#unbanchatmember
type UnbanChatMemberP struct {
@@ -33,6 +43,14 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) {
return req.Do(api)
}
// UnbanChatMemberWithContext is the context-aware variant of UnbanChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unbanchatmember
func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMemberP) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RestrictChatMemberP holds parameters for the restrictChatMember method.
// See https://core.telegram.org/bots/api#restrictchatmember
type RestrictChatMemberP struct {
@@ -51,6 +69,14 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) {
return req.Do(api)
}
// RestrictChatMemberWithContext is the context-aware variant of RestrictChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#restrictchatmember
func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMemberP) (bool, error) {
req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PromoteChatMember holds parameters for the promoteChatMember method.
// See https://core.telegram.org/bots/api#promotechatmember
type PromoteChatMember struct {
@@ -84,6 +110,14 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) {
return req.Do(api)
}
// PromoteChatMemberWithContext is the context-aware variant of PromoteChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#promotechatmember
func (api *API) PromoteChatMemberWithContext(ctx context.Context, params PromoteChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("promoteChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
type SetChatAdministratorCustomTitleP struct {
@@ -100,6 +134,14 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto
return req.Do(api)
}
// SetChatAdministratorCustomTitleWithContext is the context-aware variant of SetChatAdministratorCustomTitle.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitleP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatMemberTagP holds parameters for the setChatMemberTag method.
// See https://core.telegram.org/bots/api#setchatmembertag
type SetChatMemberTagP struct {
@@ -116,6 +158,14 @@ func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) {
return req.Do(api)
}
// SetChatMemberTagWithContext is the context-aware variant of SetChatMemberTag.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatmembertag
func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTagP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BanChatSenderChatP holds parameters for the banChatSenderChat method.
// See https://core.telegram.org/bots/api#banchatsenderchat
type BanChatSenderChatP struct {
@@ -131,6 +181,14 @@ func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) {
return req.Do(api)
}
// BanChatSenderChatWithContext is the context-aware variant of BanChatSenderChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#banchatsenderchat
func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChatP) (bool, error) {
req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method.
// See https://core.telegram.org/bots/api#unbanchatsenderchat
type UnbanChatSenderChatP struct {
@@ -146,6 +204,14 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) {
return req.Do(api)
}
// UnbanChatSenderChatWithContext is the context-aware variant of UnbanChatSenderChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unbanchatsenderchat
func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChatP) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPermissionsP holds parameters for the setChatPermissions method.
// See https://core.telegram.org/bots/api#setchatpermissions
type SetChatPermissionsP struct {
@@ -162,6 +228,14 @@ func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) {
return req.Do(api)
}
// SetChatPermissionsWithContext is the context-aware variant of SetChatPermissions.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatpermissions
func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissionsP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ExportChatInviteLinkP holds parameters for the exportChatInviteLink method.
// See https://core.telegram.org/bots/api#exportchatinvitelink
type ExportChatInviteLinkP struct {
@@ -176,6 +250,14 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, erro
return req.Do(api)
}
// ExportChatInviteLinkWithContext is the context-aware variant of ExportChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#exportchatinvitelink
func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLinkP) (string, error) {
req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatInviteLinkP holds parameters for the createChatInviteLink method.
// See https://core.telegram.org/bots/api#createchatinvitelink
type CreateChatInviteLinkP struct {
@@ -194,6 +276,14 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLi
return req.Do(api)
}
// CreateChatInviteLinkWithContext is the context-aware variant of CreateChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createchatinvitelink
func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatInviteLinkP holds parameters for the editChatInviteLink method.
// See https://core.telegram.org/bots/api#editchatinvitelink
type EditChatInviteLinkP struct {
@@ -214,6 +304,14 @@ func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink,
return req.Do(api)
}
// EditChatInviteLinkWithContext is the context-aware variant of EditChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editchatinvitelink
func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
type CreateChatSubscriptionInviteLinkP struct {
@@ -231,6 +329,14 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn
return req.Do(api)
}
// CreateChatSubscriptionInviteLinkWithContext is the context-aware variant of CreateChatSubscriptionInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
type EditChatSubscriptionInviteLinkP struct {
@@ -247,6 +353,14 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite
return req.Do(api)
}
// EditChatSubscriptionInviteLinkWithContext is the context-aware variant of EditChatSubscriptionInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method.
// See https://core.telegram.org/bots/api#revokechatinvitelink
type RevokeChatInviteLinkP struct {
@@ -262,6 +376,14 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi
return req.Do(api)
}
// RevokeChatInviteLinkWithContext is the context-aware variant of RevokeChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#revokechatinvitelink
func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method.
// See https://core.telegram.org/bots/api#approvechatjoinrequest
type ApproveChatJoinRequestP struct {
@@ -277,6 +399,14 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er
return req.Do(api)
}
// ApproveChatJoinRequestWithContext is the context-aware variant of ApproveChatJoinRequest.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#approvechatjoinrequest
func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequestP) (bool, error) {
req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method.
// See https://core.telegram.org/bots/api#declinechatjoinrequest
type DeclineChatJoinRequestP struct {
@@ -292,6 +422,14 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er
return req.Do(api)
}
// DeclineChatJoinRequestWithContext is the context-aware variant of DeclineChatJoinRequest.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#declinechatjoinrequest
func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequestP) (bool, error) {
req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPhotoP holds parameters for the setChatPhoto method.
// See https://core.telegram.org/bots/api#setchatphoto
type SetChatPhotoP struct {
@@ -325,6 +463,14 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) {
return req.Do(api)
}
// DeleteChatPhotoWithContext is the context-aware variant of DeleteChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletechatphoto
func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhotoP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatTitleP holds parameters for the setChatTitle method.
// See https://core.telegram.org/bots/api#setchattitle
type SetChatTitleP struct {
@@ -340,6 +486,14 @@ func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) {
return req.Do(api)
}
// SetChatTitleWithContext is the context-aware variant of SetChatTitle.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchattitle
func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitleP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatDescriptionP holds parameters for the setChatDescription method.
// See https://core.telegram.org/bots/api#setchatdescription
type SetChatDescriptionP struct {
@@ -355,6 +509,14 @@ func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) {
return req.Do(api)
}
// SetChatDescriptionWithContext is the context-aware variant of SetChatDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatdescription
func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescriptionP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PinChatMessageP holds parameters for the pinChatMessage method.
// See https://core.telegram.org/bots/api#pinchatmessage
type PinChatMessageP struct {
@@ -372,6 +534,14 @@ func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) {
return req.Do(api)
}
// PinChatMessageWithContext is the context-aware variant of PinChatMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#pinchatmessage
func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessageP) (bool, error) {
req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinChatMessageP holds parameters for the unpinChatMessage method.
// See https://core.telegram.org/bots/api#unpinchatmessage
type UnpinChatMessageP struct {
@@ -388,6 +558,14 @@ func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) {
return req.Do(api)
}
// UnpinChatMessageWithContext is the context-aware variant of UnpinChatMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinchatmessage
func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessageP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method.
// See https://core.telegram.org/bots/api#unpinallchatmessages
type UnpinAllChatMessagesP struct {
@@ -402,6 +580,14 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error)
return req.Do(api)
}
// UnpinAllChatMessagesWithContext is the context-aware variant of UnpinAllChatMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallchatmessages
func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessagesP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// LeaveChatP holds parameters for the leaveChat method.
// See https://core.telegram.org/bots/api#leavechat
type LeaveChatP struct {
@@ -416,6 +602,14 @@ func (api *API) LeaveChat(params LeaveChatP) (bool, error) {
return req.Do(api)
}
// LeaveChatWithContext is the context-aware variant of LeaveChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#leavechat
func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChatP) (bool, error) {
req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatP holds parameters for the getChat method.
// See https://core.telegram.org/bots/api#getchat
type GetChatP struct {
@@ -429,6 +623,14 @@ func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) {
return req.Do(api)
}
// GetChatWithContext is the context-aware variant of GetChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchat
func (api *API) GetChatWithContext(ctx context.Context, params GetChatP) (ChatFullInfo, error) {
req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatAdministratorsP holds parameters for the getChatAdministrators method.
// See https://core.telegram.org/bots/api#getchatadministrators
type GetChatAdministratorsP struct {
@@ -442,6 +644,14 @@ func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMemb
return req.Do(api)
}
// GetChatAdministratorsWithContext is the context-aware variant of GetChatAdministrators.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatadministrators
func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministratorsP) ([]ChatMember, error) {
req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMembersCountP holds parameters for the getChatMemberCount method.
// See https://core.telegram.org/bots/api#getchatmembercount
type GetChatMembersCountP struct {
@@ -455,6 +665,14 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) {
return req.Do(api)
}
// GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmembercount
func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCountP) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMemberP holds parameters for the getChatMember method.
// See https://core.telegram.org/bots/api#getchatmember
type GetChatMemberP struct {
@@ -469,6 +687,14 @@ func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) {
return req.Do(api)
}
// GetChatMemberWithContext is the context-aware variant of GetChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmember
func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMemberP) (ChatMember, error) {
req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatStickerSetP holds parameters for the setChatStickerSet method.
// See https://core.telegram.org/bots/api#setchatstickerset
type SetChatStickerSetP struct {
@@ -484,6 +710,14 @@ func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) {
return req.Do(api)
}
// SetChatStickerSetWithContext is the context-aware variant of SetChatStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatstickerset
func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSetP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method.
// See https://core.telegram.org/bots/api#deletechatstickerset
type DeleteChatStickerSetP struct {
@@ -498,6 +732,14 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error)
return req.Do(api)
}
// DeleteChatStickerSetWithContext is the context-aware variant of DeleteChatStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletechatstickerset
func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSetP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetUserChatBoostsP holds parameters for the getUserChatBoosts method.
// See https://core.telegram.org/bots/api#getuserchatboosts
type GetUserChatBoostsP struct {
@@ -512,6 +754,14 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, er
return req.Do(api)
}
// GetUserChatBoostsWithContext is the context-aware variant of GetUserChatBoosts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getuserchatboosts
func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoostsP) (UserChatBoosts, error) {
req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatGiftsP holds parameters for the getChatGifts method.
// See https://core.telegram.org/bots/api#getchatgifts
type GetChatGiftsP struct {
@@ -534,3 +784,11 @@ func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.Do(api)
}
// GetChatGiftsWithContext is the context-aware variant of GetChatGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatgifts
func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGiftsP) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+19 -9
View File
@@ -17,10 +17,14 @@ type Chat struct {
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
// ChatTypePrivate identifies a private chat.
ChatTypePrivate ChatType = "private"
// ChatTypeGroup identifies a basic group chat.
ChatTypeGroup ChatType = "group"
// ChatTypeSupergroup identifies a supergroup chat.
ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel"
// ChatTypeChannel identifies a channel chat.
ChatTypeChannel ChatType = "channel"
)
// ChatFullInfo contains full information about a chat.
@@ -143,12 +147,18 @@ type ChatInviteLink struct {
type ChatMemberStatusType string
const (
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusOwner identifies a chat owner.
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusAdministrator identifies a chat administrator.
ChatMemberStatusAdministrator ChatMemberStatusType = "administrator"
ChatMemberStatusMember ChatMemberStatusType = "member"
ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
ChatMemberStatusLeft ChatMemberStatusType = "left"
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
// ChatMemberStatusMember identifies a regular member.
ChatMemberStatusMember ChatMemberStatusType = "member"
// ChatMemberStatusRestricted identifies a restricted member.
ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
// ChatMemberStatusLeft identifies a user who left the chat.
ChatMemberStatusLeft ChatMemberStatusType = "left"
// ChatMemberStatusBanned identifies a banned user.
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
)
// ChatMember contains information about one member of a chat.
@@ -214,7 +224,7 @@ type ChatBoostSource struct {
// ChatBoost represents a boost added to a chat.
// See https://core.telegram.org/bots/api#chatboost
type ChatBoost struct {
BoostID int `json:"boost_id"`
BoostID string `json:"boost_id"`
AddDate int `json:"add_date"`
ExpirationDate int `json:"expiration_date"`
Source ChatBoostSource `json:"source"`
+7
View File
@@ -2,7 +2,14 @@ package tgapi
import "errors"
// ErrRateLimit reports that a request exceeded the configured rate limiter.
var ErrRateLimit = errors.New("rate limit exceeded")
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
var ErrPoolUnexpected = errors.New("unexpected response from pool")
// ErrPoolQueueFull reports that the internal request queue is full.
var ErrPoolQueueFull = errors.New("worker pool queue full")
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
var ErrPoolStopped = errors.New("worker pool stopped")
+106
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID.
type BaseForumTopicP struct {
ChatID int64 `json:"chat_id"`
@@ -13,6 +15,14 @@ func (api *API) GetForumTopicIconStickers() ([]Sticker, error) {
return req.Do(api)
}
// GetForumTopicIconStickersWithContext is the context-aware variant of GetForumTopicIconStickers.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getforumtopiciconstickers
func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams)
return req.DoWithContext(ctx, api)
}
// CreateForumTopicP holds parameters for the createForumTopic method.
// See https://core.telegram.org/bots/api#createforumtopic
type CreateForumTopicP struct {
@@ -30,6 +40,14 @@ func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) {
return req.Do(api)
}
// CreateForumTopicWithContext is the context-aware variant of CreateForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createforumtopic
func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopicP) (ForumTopic, error) {
req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditForumTopicP holds parameters for the editForumTopic method.
// See https://core.telegram.org/bots/api#editforumtopic
type EditForumTopicP struct {
@@ -46,6 +64,14 @@ func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) {
return req.Do(api)
}
// EditForumTopicWithContext is the context-aware variant of EditForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editforumtopic
func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CloseForumTopic closes an open forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#closeforumtopic
@@ -54,6 +80,14 @@ func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) {
return req.Do(api)
}
// CloseForumTopicWithContext is the context-aware variant of CloseForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#closeforumtopic
func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ReopenForumTopic reopens a closed forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#reopenforumtopic
@@ -62,6 +96,14 @@ func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) {
return req.Do(api)
}
// ReopenForumTopicWithContext is the context-aware variant of ReopenForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#reopenforumtopic
func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteForumTopic deletes a forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#deleteforumtopic
@@ -70,6 +112,14 @@ func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) {
return req.Do(api)
}
// DeleteForumTopicWithContext is the context-aware variant of DeleteForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deleteforumtopic
func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages
@@ -78,6 +128,14 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error)
return req.Do(api)
}
// UnpinAllForumTopicMessagesWithContext is the context-aware variant of UnpinAllForumTopicMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages
func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID.
type BaseGeneralForumTopicP struct {
ChatID int64 `json:"chat_id"`
@@ -98,6 +156,14 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, erro
return req.Do(api)
}
// EditGeneralForumTopicWithContext is the context-aware variant of EditGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editgeneralforumtopic
func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CloseGeneralForumTopic closes the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#closegeneralforumtopic
@@ -106,6 +172,14 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, err
return req.Do(api)
}
// CloseGeneralForumTopicWithContext is the context-aware variant of CloseGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#closegeneralforumtopic
func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic
@@ -114,6 +188,14 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
return req.Do(api)
}
// ReopenGeneralForumTopicWithContext is the context-aware variant of ReopenGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic
func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// HideGeneralForumTopic hides the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic
@@ -122,6 +204,14 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, erro
return req.Do(api)
}
// HideGeneralForumTopicWithContext is the context-aware variant of HideGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic
func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic
@@ -130,6 +220,14 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
return req.Do(api)
}
// UnhideGeneralForumTopicWithContext is the context-aware variant of UnhideGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic
func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
@@ -137,3 +235,11 @@ func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP)
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.Do(api)
}
// UnpinAllGeneralForumTopicMessagesWithContext is the context-aware variant of UnpinAllGeneralForumTopicMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+33
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SendGameP holds parameters for the sendGame method.
// See https://core.telegram.org/bots/api#sendgame
type SendGameP struct {
@@ -24,6 +26,14 @@ func (api *API) SendGame(params SendGameP) (Message, error) {
return req.Do(api)
}
// SendGameWithContext is the context-aware variant of SendGame.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendgame
func (api *API) SendGameWithContext(ctx context.Context, params SendGameP) (Message, error) {
req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetGameScoreP holds parameters for the setGameScore method.
// See https://core.telegram.org/bots/api#setgamescore
type SetGameScoreP struct {
@@ -52,6 +62,21 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) {
return res, false, err
}
// SetGameScoreWithContext is the context-aware variant of SetGameScore.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setgamescore
func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScoreP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("setGameScore", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// GetGameHighScoresP holds parameters for the getGameHighScores method.
// See https://core.telegram.org/bots/api#getgamehighscores
type GetGameHighScoresP struct {
@@ -67,3 +92,11 @@ func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, e
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.Do(api)
}
// GetGameHighScoresWithContext is the context-aware variant of GetGameHighScores.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getgamehighscores
func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScoresP) ([]GameHighScore, error) {
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+26
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// AnswerInlineQueryP holds parameters for the answerInlineQuery method.
// See https://core.telegram.org/bots/api#answerinlinequery
type AnswerInlineQueryP struct {
@@ -19,6 +21,14 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) {
return req.Do(api)
}
// AnswerInlineQueryWithContext is the context-aware variant of AnswerInlineQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerinlinequery
func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQueryP) (bool, error) {
req := NewRequest[bool]("answerInlineQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method.
// See https://core.telegram.org/bots/api#answerwebappquery
type AnswerWebAppQueryP struct {
@@ -33,6 +43,14 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage,
return req.Do(api)
}
// AnswerWebAppQueryWithContext is the context-aware variant of AnswerWebAppQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerwebappquery
func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQueryP) (SentWebAppMessage, error) {
req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params)
return req.DoWithContext(ctx, api)
}
// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
type SavePreparedInlineMessageP struct {
@@ -50,3 +68,11 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (Pr
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.Do(api)
}
// SavePreparedInlineMessageWithContext is the context-aware variant of SavePreparedInlineMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessageP) (PreparedInlineMessage, error) {
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.DoWithContext(ctx, api)
}
+285 -18
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SendMessageP holds parameters for the sendMessage method.
// See https://core.telegram.org/bots/api#sendmessage
type SendMessageP struct {
@@ -29,6 +31,14 @@ func (api *API) SendMessage(params SendMessageP) (Message, error) {
return req.Do(api)
}
// SendMessageWithContext is the context-aware variant of SendMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmessage
func (api *API) SendMessageWithContext(ctx context.Context, params SendMessageP) (Message, error) {
req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessageP holds parameters for the forwardMessage method.
// See https://core.telegram.org/bots/api#forwardmessage
type ForwardMessageP struct {
@@ -53,6 +63,14 @@ func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) {
return req.Do(api)
}
// ForwardMessageWithContext is the context-aware variant of ForwardMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#forwardmessage
func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessageP) (Message, error) {
req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessagesP holds parameters for the forwardMessages method.
// See https://core.telegram.org/bots/api#forwardmessages
type ForwardMessagesP struct {
@@ -74,6 +92,14 @@ func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) {
return req.Do(api)
}
// ForwardMessagesWithContext is the context-aware variant of ForwardMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#forwardmessages
func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessagesP) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CopyMessageP holds parameters for the copyMessage method.
// See https://core.telegram.org/bots/api#copymessage
type CopyMessageP struct {
@@ -110,6 +136,17 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) {
return msgID.MessageID, nil
}
// CopyMessageWithContext is the context-aware variant of CopyMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#copymessage
func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessageP) (int, error) {
msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).DoWithContext(ctx, api)
if err != nil {
return 0, err
}
return msgID.MessageID, nil
}
// CopyMessagesP holds parameters for the copyMessages method.
// See https://core.telegram.org/bots/api#copymessages
type CopyMessagesP struct {
@@ -132,6 +169,14 @@ func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) {
return req.Do(api)
}
// CopyMessagesWithContext is the context-aware variant of CopyMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#copymessages
func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessagesP) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendLocationP holds parameters for the sendLocation method.
// See https://core.telegram.org/bots/api#sendlocation
type SendLocationP struct {
@@ -164,6 +209,14 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) {
return req.Do(api)
}
// SendLocationWithContext is the context-aware variant of SendLocation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendlocation
func (api *API) SendLocationWithContext(ctx context.Context, params SendLocationP) (Message, error) {
req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVenueP holds parameters for the sendVenue method.
// See https://core.telegram.org/bots/api#sendvenue
type SendVenueP struct {
@@ -198,6 +251,14 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) {
return req.Do(api)
}
// SendVenueWithContext is the context-aware variant of SendVenue.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvenue
func (api *API) SendVenueWithContext(ctx context.Context, params SendVenueP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendContactP holds parameters for the sendContact method.
// See https://core.telegram.org/bots/api#sendcontact
type SendContactP struct {
@@ -228,6 +289,14 @@ func (api *API) SendContact(params SendContactP) (Message, error) {
return req.Do(api)
}
// SendContactWithContext is the context-aware variant of SendContact.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendcontact
func (api *API) SendContactWithContext(ctx context.Context, params SendContactP) (Message, error) {
req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPollP holds parameters for the sendPoll method.
// See https://core.telegram.org/bots/api#sendpoll
type SendPollP struct {
@@ -266,6 +335,14 @@ func (api *API) SendPoll(params SendPollP) (Message, error) {
return req.Do(api)
}
// SendPollWithContext is the context-aware variant of SendPoll.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendpoll
func (api *API) SendPollWithContext(ctx context.Context, params SendPollP) (Message, error) {
req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChecklistP holds parameters for the sendChecklist method.
// See https://core.telegram.org/bots/api#sendchecklist
type SendChecklistP struct {
@@ -288,6 +365,14 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) {
return req.Do(api)
}
// SendChecklistWithContext is the context-aware variant of SendChecklist.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendchecklist
func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklistP) (Message, error) {
req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDiceP holds parameters for the sendDice method.
// See https://core.telegram.org/bots/api#senddice
type SendDiceP struct {
@@ -315,6 +400,14 @@ func (api *API) SendDice(params SendDiceP) (Message, error) {
return req.Do(api)
}
// SendDiceWithContext is the context-aware variant of SendDice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddice
func (api *API) SendDiceWithContext(ctx context.Context, params SendDiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMessageDraftP holds parameters for the sendMessageDraft method.
// See https://core.telegram.org/bots/api#sendmessagedraft
type SendMessageDraftP struct {
@@ -334,6 +427,14 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) {
return req.Do(api)
}
// SendMessageDraftWithContext is the context-aware variant of SendMessageDraft.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmessagedraft
func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraftP) (bool, error) {
req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChatActionP holds parameters for the sendChatAction method.
// See https://core.telegram.org/bots/api#sendchataction
type SendChatActionP struct {
@@ -351,6 +452,14 @@ func (api *API) SendChatAction(params SendChatActionP) (bool, error) {
return req.Do(api)
}
// SendChatActionWithContext is the context-aware variant of SendChatAction.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendchataction
func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatActionP) (bool, error) {
req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetMessageReactionP holds parameters for the setMessageReaction method.
// See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReactionP struct {
@@ -368,16 +477,26 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) {
return req.Do(api)
}
// SetMessageReactionWithContext is the context-aware variant of SetMessageReaction.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmessagereaction
func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReactionP) (bool, error) {
req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageTextP holds parameters for the editMessageText method.
// See https://core.telegram.org/bots/api#editmessagetext
type EditMessageTextP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
}
// EditMessageText edits text messages.
@@ -396,16 +515,33 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error)
return res, false, err
}
// EditMessageTextWithContext is the context-aware variant of EditMessageText.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagetext
func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageTextP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageText", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageCaptionP holds parameters for the editMessageCaption method.
// See https://core.telegram.org/bots/api#editmessagecaption
type EditMessageCaptionP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"`
Caption string `json:"caption"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"`
Caption string `json:"caption"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
}
// EditMessageCaption edits captions of messages.
@@ -424,6 +560,21 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e
return res, false, err
}
// EditMessageCaptionWithContext is the context-aware variant of EditMessageCaption.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagecaption
func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaptionP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageCaption", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageMediaP holds parameters for the editMessageMedia method.
// See https://core.telegram.org/bots/api#editmessagemedia
type EditMessageMediaP struct {
@@ -451,6 +602,21 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error
return res, false, err
}
// EditMessageMediaWithContext is the context-aware variant of EditMessageMedia.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagemedia
func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMediaP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageMedia", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method.
// See https://core.telegram.org/bots/api#editmessagelivelocation
type EditMessageLiveLocationP struct {
@@ -484,6 +650,21 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag
return res, false, err
}
// EditMessageLiveLocationWithContext is the context-aware variant of EditMessageLiveLocation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagelivelocation
func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocationP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method.
// See https://core.telegram.org/bots/api#stopmessagelivelocation
type StopMessageLiveLocationP struct {
@@ -510,6 +691,21 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag
return res, false, err
}
// StopMessageLiveLocationWithContext is the context-aware variant of StopMessageLiveLocation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#stopmessagelivelocation
func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocationP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("stopMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageChecklistP holds parameters for the editMessageChecklist method.
type EditMessageChecklistP struct {
BusinessConnectionID string `json:"business_connection_id"`
@@ -526,6 +722,14 @@ func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, err
return req.Do(api)
}
// EditMessageChecklistWithContext is the context-aware variant of EditMessageChecklist.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagechecklist
func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklistP) (Message, error) {
req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method.
// See https://core.telegram.org/bots/api#editmessagereplymarkup
type EditMessageReplyMarkupP struct {
@@ -552,13 +756,28 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message,
return res, false, err
}
// EditMessageReplyMarkupWithContext is the context-aware variant of EditMessageReplyMarkup.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagereplymarkup
func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkupP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageReplyMarkup", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// StopPollP holds parameters for the stopPoll method.
// See https://core.telegram.org/bots/api#stoppoll
type StopPollP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
InlineMessageID string `json:"inline_message_id,omitempty"`
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
}
// StopPoll stops a poll that was sent by the bot.
@@ -569,6 +788,14 @@ func (api *API) StopPoll(params StopPollP) (Poll, error) {
return req.Do(api)
}
// StopPollWithContext is the context-aware variant of StopPoll.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#stoppoll
func (api *API) StopPollWithContext(ctx context.Context, params StopPollP) (Poll, error) {
req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveSuggestedPostP holds parameters for the approveSuggestedPost method.
// See https://core.telegram.org/bots/api#approvesuggestedpost
type ApproveSuggestedPostP struct {
@@ -585,6 +812,14 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error)
return req.Do(api)
}
// ApproveSuggestedPostWithContext is the context-aware variant of ApproveSuggestedPost.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#approvesuggestedpost
func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPostP) (bool, error) {
req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineSuggestedPostP holds parameters for the declineSuggestedPost method.
// See https://core.telegram.org/bots/api#declinesuggestedpost
type DeclineSuggestedPostP struct {
@@ -601,6 +836,14 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error)
return req.Do(api)
}
// DeclineSuggestedPostWithContext is the context-aware variant of DeclineSuggestedPost.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#declinesuggestedpost
func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPostP) (bool, error) {
req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessageP holds parameters for the deleteMessage method.
// See https://core.telegram.org/bots/api#deletemessage
type DeleteMessageP struct {
@@ -616,6 +859,14 @@ func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) {
return req.Do(api)
}
// DeleteMessageWithContext is the context-aware variant of DeleteMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemessage
func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessageP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessagesP holds parameters for the deleteMessages method.
// See https://core.telegram.org/bots/api#deletemessages
type DeleteMessagesP struct {
@@ -631,6 +882,14 @@ func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) {
return req.Do(api)
}
// DeleteMessagesWithContext is the context-aware variant of DeleteMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemessages
func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessagesP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// AnswerCallbackQueryP holds parameters for the answerCallbackQuery method.
// See https://core.telegram.org/bots/api#answercallbackquery
type AnswerCallbackQueryP struct {
@@ -648,3 +907,11 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
return req.Do(api)
}
// AnswerCallbackQueryWithContext is the context-aware variant of AnswerCallbackQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answercallbackquery
func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQueryP) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
return req.DoWithContext(ctx, api)
}
+146 -52
View File
@@ -45,9 +45,9 @@ type Message struct {
Text string `json:"text"`
Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"`
Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"`
Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
Date int `json:"date"`
EditDate int `json:"edit_date"`
@@ -77,26 +77,46 @@ type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage }
type MessageEntityType string
const (
MessageEntityMention MessageEntityType = "mention"
MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityUrl MessageEntityType = "url"
MessageEntityEmail MessageEntityType = "email"
MessageEntityPhoneNumber MessageEntityType = "phone_number"
MessageEntityBold MessageEntityType = "bold"
MessageEntityItalic MessageEntityType = "italic"
MessageEntityUnderline MessageEntityType = "underline"
MessageEntityStrike MessageEntityType = "strikethrough"
MessageEntitySpoiler MessageEntityType = "spoiler"
MessageEntityBlockquote MessageEntityType = "blockquote"
// MessageEntityMention identifies an @mention entity.
MessageEntityMention MessageEntityType = "mention"
// MessageEntityHashtag identifies a hashtag entity.
MessageEntityHashtag MessageEntityType = "hashtag"
// MessageEntityCashtag identifies a cashtag entity.
MessageEntityCashtag MessageEntityType = "cashtag"
// MessageEntityBotCommand identifies a bot command entity.
MessageEntityBotCommand MessageEntityType = "bot_command"
// MessageEntityUrl identifies a URL entity.
MessageEntityUrl MessageEntityType = "url"
// MessageEntityEmail identifies an email entity.
MessageEntityEmail MessageEntityType = "email"
// MessageEntityPhoneNumber identifies a phone number entity.
MessageEntityPhoneNumber MessageEntityType = "phone_number"
// MessageEntityBold identifies bold text.
MessageEntityBold MessageEntityType = "bold"
// MessageEntityItalic identifies italic text.
MessageEntityItalic MessageEntityType = "italic"
// MessageEntityUnderline identifies underlined text.
MessageEntityUnderline MessageEntityType = "underline"
// MessageEntityStrike identifies strikethrough text.
MessageEntityStrike MessageEntityType = "strikethrough"
// MessageEntitySpoiler identifies spoiler text.
MessageEntitySpoiler MessageEntityType = "spoiler"
// MessageEntityBlockquote identifies a blockquote entity.
MessageEntityBlockquote MessageEntityType = "blockquote"
// MessageEntityExpandableBlockquote identifies an expandable blockquote entity.
MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote"
MessageEntityCode MessageEntityType = "code"
MessageEntityPre MessageEntityType = "pre"
MessageEntityTextLink MessageEntityType = "text_link"
MessageEntityTextMention MessageEntityType = "text_mention"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
MessageEntityDateTime MessageEntityType = "date_time"
// MessageEntityCode identifies inline code.
MessageEntityCode MessageEntityType = "code"
// MessageEntityPre identifies a preformatted block.
MessageEntityPre MessageEntityType = "pre"
// MessageEntityTextLink identifies linked text.
MessageEntityTextLink MessageEntityType = "text_link"
// MessageEntityTextMention identifies a text mention.
MessageEntityTextMention MessageEntityType = "text_mention"
// MessageEntityCustomEmoji identifies a custom emoji entity.
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
// MessageEntityDateTime identifies a date-time entity.
MessageEntityDateTime MessageEntityType = "date_time"
)
// MessageEntity represents one special entity in a text message.
@@ -121,12 +141,12 @@ type ReplyParameters struct {
MessageID int `json:"message_id"`
ChatID int64 `json:"chat_id,omitempty"`
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
Quote string `json:"quote,omitempty"`
QuoteParsingMode string `json:"quote_parsing_mode,omitempty"`
QuoteEntities []*MessageEntity `json:"quote_entities,omitempty"`
QuotePosition int `json:"quote_position,omitempty"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
Quote string `json:"quote,omitempty"`
QuoteParsingMode string `json:"quote_parsing_mode,omitempty"`
QuoteEntities []MessageEntity `json:"quote_entities,omitempty"`
QuotePosition int `json:"quote_position,omitempty"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
}
// LinkPreviewOptions describes the options used for link preview generation.
@@ -144,12 +164,12 @@ type LinkPreviewOptions struct {
type ReplyMarkup struct {
InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"`
Keyboard [][]int `json:"keyboard,omitempty"`
IsPersistent bool `json:"is_persistent,omitempty"`
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
Selective bool `json:"selective,omitempty"`
Keyboard [][]KeyboardButton `json:"keyboard,omitempty"`
IsPersistent bool `json:"is_persistent,omitempty"`
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
Selective bool `json:"selective,omitempty"`
RemoveKeyboard bool `json:"remove_keyboard,omitempty"`
@@ -165,6 +185,63 @@ type InlineKeyboardMarkup struct {
// KeyboardButtonStyle represents the style of a keyboard button.
type KeyboardButtonStyle string
const (
// KeyboardButtonStyleDanger marks a destructive keyboard button.
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
// KeyboardButtonStyleSuccess marks a confirmatory keyboard button.
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
// KeyboardButtonStylePrimary marks a primary keyboard button.
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
)
// KeyboardButton represents one button of the reply keyboard.
// See https://core.telegram.org/bots/api#keyboardbutton
type KeyboardButton struct {
Text string `json:"text"`
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
Style KeyboardButtonStyle `json:"style,omitempty"`
RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"`
RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"`
RequestContact bool `json:"request_contact,omitempty"`
RequestLocation bool `json:"request_location,omitempty"`
RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"`
WebApp *WebAppInfo `json:"web_app,omitempty"`
}
// KeyboardButtonRequestUsers defines criteria used to request suitable users.
// See https://core.telegram.org/bots/api#keyboardbuttonrequestusers
type KeyboardButtonRequestUsers struct {
RequestID int `json:"request_id"`
UserIsBot *bool `json:"user_is_bot,omitempty"`
UserIsPremium *bool `json:"user_is_premium,omitempty"`
MaxQuantity int `json:"max_quantity,omitempty"`
RequestName bool `json:"request_name,omitempty"`
RequestUsername bool `json:"request_username,omitempty"`
RequestPhoto bool `json:"request_photo,omitempty"`
}
// KeyboardButtonRequestChat defines criteria used to request a suitable chat.
// See https://core.telegram.org/bots/api#keyboardbuttonrequestchat
type KeyboardButtonRequestChat struct {
RequestID int `json:"request_id"`
ChatIsChannel bool `json:"chat_is_channel"`
ChatIsForum *bool `json:"chat_is_forum,omitempty"`
ChatHasUsername *bool `json:"chat_has_username,omitempty"`
ChatIsCreated *bool `json:"chat_is_created,omitempty"`
UserAdministratorRights *ChatAdministratorRights `json:"user_administrator_rights,omitempty"`
BotAdministratorRights *ChatAdministratorRights `json:"bot_administrator_rights,omitempty"`
BotIsMember bool `json:"bot_is_member,omitempty"`
RequestTitle bool `json:"request_title,omitempty"`
RequestUsername bool `json:"request_username,omitempty"`
RequestPhoto bool `json:"request_photo,omitempty"`
}
// KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button.
// See https://core.telegram.org/bots/api#keyboardbuttonpolltype
type KeyboardButtonPollType struct {
Type PollType `json:"type,omitempty"`
}
// InlineKeyboardButton represents one button of an inline keyboard.
// See https://core.telegram.org/bots/api#inlinekeyboardbutton
type InlineKeyboardButton struct {
@@ -178,7 +255,12 @@ type InlineKeyboardButton struct {
// ReplyKeyboardMarkup represents a custom keyboard with reply options.
// See https://core.telegram.org/bots/api#replykeyboardmarkup
type ReplyKeyboardMarkup struct {
Keyboard [][]int `json:"keyboard"`
Keyboard [][]KeyboardButton `json:"keyboard"`
IsPersistent bool `json:"is_persistent,omitempty"`
ResizeKeyboard bool `json:"resize_keyboard,omitempty"`
OneTimeKeyboard bool `json:"one_time_keyboard,omitempty"`
InputFieldPlaceholder string `json:"input_field_placeholder,omitempty"`
Selective bool `json:"selective,omitempty"`
}
// CallbackQuery represents an incoming callback query from a callback button in an inline keyboard.
@@ -196,32 +278,34 @@ type CallbackQuery struct {
// InputPollOption contains information about one answer option in a poll to be sent.
// See https://core.telegram.org/bots/api#inputpolloption
type InputPollOption struct {
Text string `json:"text"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
TextEntities []*MessageEntity `json:"text_entities,omitempty"`
Text string `json:"text"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
TextEntities []MessageEntity `json:"text_entities,omitempty"`
}
// PollType represents the type of a poll.
type PollType string
const (
// PollTypeRegular identifies a regular poll.
PollTypeRegular PollType = "regular"
PollTypeQuiz PollType = "quiz"
// PollTypeQuiz identifies a quiz poll.
PollTypeQuiz PollType = "quiz"
)
// InputChecklistTask describes a task in a checklist.
type InputChecklistTask struct {
ID int `json:"id"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
TextEntities []*MessageEntity `json:"text_entities,omitempty"`
ID int `json:"id"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
TextEntities []MessageEntity `json:"text_entities,omitempty"`
}
// InputChecklist represents a checklist to be sent.
type InputChecklist struct {
Title string `json:"title"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
TitleEntities []*MessageEntity `json:"title_entities,omitempty"`
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
Tasks []InputChecklistTask `json:"tasks"`
OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"`
OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"`
@@ -231,14 +315,24 @@ type InputChecklist struct {
type ChatActionType string
const (
ChatActionTyping ChatActionType = "typing"
ChatActionUploadPhoto ChatActionType = "upload_photo"
ChatActionUploadVideo ChatActionType = "upload_video"
ChatActionUploadVoice ChatActionType = "upload_voice"
ChatActionUploadDocument ChatActionType = "upload_document"
ChatActionChooseSticker ChatActionType = "choose_sticker"
ChatActionFindLocation ChatActionType = "find_location"
ChatActionUploadVideoNone ChatActionType = "upload_video_none"
// ChatActionTyping tells Telegram the bot is typing.
ChatActionTyping ChatActionType = "typing"
// ChatActionUploadPhoto tells Telegram the bot is uploading a photo.
ChatActionUploadPhoto ChatActionType = "upload_photo"
// ChatActionUploadVideo tells Telegram the bot is uploading a video.
ChatActionUploadVideo ChatActionType = "upload_video"
// ChatActionUploadVoice tells Telegram the bot is uploading a voice message.
ChatActionUploadVoice ChatActionType = "upload_voice"
// ChatActionUploadDocument tells Telegram the bot is uploading a document.
ChatActionUploadDocument ChatActionType = "upload_document"
// ChatActionChooseSticker tells Telegram the bot is choosing a sticker.
ChatActionChooseSticker ChatActionType = "choose_sticker"
// ChatActionFindLocation tells Telegram the bot is finding a location.
ChatActionFindLocation ChatActionType = "find_location"
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
ChatActionUploadVideoNote ChatActionType = "upload_video_note"
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
)
// MessageReactionUpdated represents a change of a reaction on a message.
+37
View File
@@ -0,0 +1,37 @@
package tgapi
import (
"encoding/json"
"strings"
"testing"
)
func TestReplyKeyboardMarkupMarshalsKeyboardButtons(t *testing.T) {
markup := ReplyKeyboardMarkup{
Keyboard: [][]KeyboardButton{{
{
Text: "Create poll",
RequestPoll: &KeyboardButtonPollType{Type: PollTypeQuiz},
},
}},
}
data, err := json.Marshal(markup)
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
got := string(data)
if !strings.Contains(got, `"keyboard":[[{"text":"Create poll","request_poll":{"type":"quiz"}}]]`) {
t.Fatalf("unexpected reply keyboard JSON: %s", got)
}
}
func TestChatActionUploadVideoNoteValue(t *testing.T) {
if ChatActionUploadVideoNote != "upload_video_note" {
t.Fatalf("unexpected chat action value: %q", ChatActionUploadVideoNote)
}
if ChatActionUploadVideoNone != ChatActionUploadVideoNote {
t.Fatalf("expected deprecated alias to match upload_video_note, got %q", ChatActionUploadVideoNone)
}
}
+133 -8
View File
@@ -1,9 +1,12 @@
package tgapi
import (
"context"
"fmt"
"io"
"net/http"
"git.nix13.pw/scuroneko/laniakea/utils"
)
// UpdateParams holds parameters for the getUpdates method.
@@ -12,7 +15,7 @@ type UpdateParams struct {
Offset *int `json:"offset,omitempty"`
Limit *int `json:"limit,omitempty"`
Timeout *int `json:"timeout,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
}
// GetMe returns basic information about the bot.
@@ -22,6 +25,14 @@ func (api *API) GetMe() (User, error) {
return req.Do(api)
}
// GetMeWithContext is the context-aware variant of GetMe.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getme
func (api *API) GetMeWithContext(ctx context.Context) (User, error) {
req := NewRequest[User, EmptyParams]("getMe", NoParams)
return req.DoWithContext(ctx, api)
}
// LogOut logs the bot out from the cloud Bot API server.
// Returns true on success.
// See https://core.telegram.org/bots/api#logout
@@ -30,14 +41,30 @@ func (api *API) LogOut() (bool, error) {
return req.Do(api)
}
// Close closes the bot instance on the local server.
// LogOutWithContext is the context-aware variant of LogOut.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#logout
func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool, EmptyParams]("logOut", NoParams)
return req.DoWithContext(ctx, api)
}
// CloseRemote closes the bot instance on the local server.
// Returns true on success.
// See https://core.telegram.org/bots/api#close
func (api *API) Close() (bool, error) {
func (api *API) CloseRemote() (bool, error) {
req := NewRequest[bool, EmptyParams]("close", NoParams)
return req.Do(api)
}
// CloseRemoteWithContext is the context-aware variant of CloseRemote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#close
func (api *API) CloseRemoteWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool, EmptyParams]("close", NoParams)
return req.DoWithContext(ctx, api)
}
// GetUpdates receives incoming updates using long polling.
// See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
@@ -45,11 +72,19 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
return req.Do(api)
}
// GetUpdatesWithContext is the context-aware variant of GetUpdates.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) {
req := NewRequest[[]Update]("getUpdates", params)
return req.DoWithContext(ctx, api)
}
// SetWebhookP holds parameters for the setWebhook method.
// To upload a self-signed certificate, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook
type SetWebhookP struct {
URL string `json:"url"`
Certificate string `json:"certificate,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
@@ -58,6 +93,7 @@ type SetWebhookP struct {
}
// SetWebhook sets a webhook URL for incoming updates.
// For certificate upload, use Uploader.SetWebhook.
// Returns true on success.
// See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
@@ -65,6 +101,15 @@ func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
return req.Do(api)
}
// SetWebhookWithContext is the context-aware variant of SetWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// For certificate upload, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhookP) (bool, error) {
req := NewRequest[bool]("setWebhook", params)
return req.DoWithContext(ctx, api)
}
// DeleteWebhookP holds parameters for the deleteWebhook method.
// See https://core.telegram.org/bots/api#deletewebhook
type DeleteWebhookP struct {
@@ -79,6 +124,14 @@ func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) {
return req.Do(api)
}
// DeleteWebhookWithContext is the context-aware variant of DeleteWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletewebhook
func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhookP) (bool, error) {
req := NewRequest[bool]("deleteWebhook", params)
return req.DoWithContext(ctx, api)
}
// GetWebhookInfo returns the current webhook status.
// See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfo() (WebhookInfo, error) {
@@ -86,6 +139,14 @@ func (api *API) GetWebhookInfo() (WebhookInfo, error) {
return req.Do(api)
}
// GetWebhookInfoWithContext is the context-aware variant of GetWebhookInfo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfoWithContext(ctx context.Context) (WebhookInfo, error) {
req := NewRequest[WebhookInfo]("getWebhookInfo", NoParams)
return req.DoWithContext(ctx, api)
}
// GetFileP holds parameters for the getFile method.
// See https://core.telegram.org/bots/api#getfile
type GetFileP struct {
@@ -99,17 +160,81 @@ func (api *API) GetFile(params GetFileP) (File, error) {
return req.Do(api)
}
// GetFileWithContext is the context-aware variant of GetFile.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getfile
func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File, error) {
req := NewRequest[File]("getFile", params)
return req.DoWithContext(ctx, api)
}
// GetFileByLink downloads a file from Telegram's file server using the provided file link.
// The link is usually obtained from File.FilePath.
// For large files, prefer OpenFileByLink or OpenFileByLinkWithContext to stream the response body.
// See https://core.telegram.org/bots/api#file
func (api *API) GetFileByLink(link string) ([]byte, error) {
u := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", api.token, link)
res, err := http.Get(u)
return api.getFileByLink(context.Background(), link)
}
// GetFileByLinkWithContext is the context-aware variant of GetFileByLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// For large files, prefer OpenFileByLinkWithContext to stream the response body.
// See https://core.telegram.org/bots/api#file
func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) {
return api.getFileByLink(ctx, link)
}
// OpenFileByLink opens a streaming response body for a file hosted on Telegram's file server.
// The caller must close the returned ReadCloser.
// See https://core.telegram.org/bots/api#file
func (api *API) OpenFileByLink(link string) (io.ReadCloser, error) {
return api.openFileByLink(context.Background(), link)
}
// OpenFileByLinkWithContext is the context-aware variant of OpenFileByLink.
// The caller must close the returned ReadCloser.
// See https://core.telegram.org/bots/api#file
func (api *API) OpenFileByLinkWithContext(ctx context.Context, link string) (io.ReadCloser, error) {
return api.openFileByLink(ctx, link)
}
func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) {
body, err := api.openFileByLink(ctx, link)
if err != nil {
return nil, err
}
defer func() {
_ = res.Body.Close()
_ = body.Close()
}()
return io.ReadAll(res.Body)
return io.ReadAll(body)
}
func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, error) {
methodPrefix := ""
if api.useTestServer {
methodPrefix = "/test"
}
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
res, err := api.client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
defer func() {
_ = res.Body.Close()
}()
body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10))
if readErr != nil {
return nil, fmt.Errorf("unexpected status %d", res.StatusCode)
}
return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body))
}
return res.Body, nil
}
+153
View File
@@ -0,0 +1,153 @@
package tgapi
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
)
func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
var gotPath string
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("payload")),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
data, err := api.GetFileByLink("files/report.txt")
if err != nil {
t.Fatalf("GetFileByLink returned error: %v", err)
}
if string(data) != "payload" {
t.Fatalf("unexpected payload: %q", string(data))
}
if gotPath != "/file/bottoken/files/report.txt" {
t.Fatalf("unexpected request path: %s", gotPath)
}
}
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("streamed payload")),
}, nil
}),
}),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
body, err := api.OpenFileByLink("files/report.txt")
if err != nil {
t.Fatalf("OpenFileByLink returned error: %v", err)
}
defer func() {
if err := body.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if string(data) != "streamed payload" {
t.Fatalf("unexpected payload: %q", string(data))
}
}
func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(strings.NewReader("missing\n")),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
_, err := api.GetFileByLink("files/report.txt")
if err == nil {
t.Fatal("expected error for non-2xx response")
}
}
func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":[]}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
updates, err := api.GetUpdates(UpdateParams{})
if err != nil {
t.Fatalf("GetUpdates returned error: %v", err)
}
if len(updates) != 0 {
t.Fatalf("expected no updates, got %d", len(updates))
}
if _, exists := gotBody["allowed_updates"]; exists {
t.Fatalf("expected allowed_updates to be omitted, got %v", gotBody["allowed_updates"])
}
}
+2 -2
View File
@@ -10,8 +10,8 @@ const (
ParseHTML ParseMode = "HTML"
// ParseMD enables legacy Markdown style parsing.
ParseMD ParseMode = "Markdown"
// ParseNone disables any parsing.
ParseNone ParseMode = "None"
// ParseNone disables parse_mode and leaves plain-text requests unannotated.
ParseNone ParseMode = ""
)
// EmptyParams is a placeholder for methods that take no parameters.
+37
View File
@@ -0,0 +1,37 @@
package tgapi
import (
"encoding/json"
"strings"
"testing"
)
func TestParseNoneOmitsParseModeInJSON(t *testing.T) {
data, err := json.Marshal(SendMessageP{
ChatID: 42,
Text: "hello",
ParseMode: ParseNone,
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if strings.Contains(string(data), `"parse_mode"`) {
t.Fatalf("expected parse_mode to be omitted, got %s", string(data))
}
}
func TestParseModeStillSerializesExplicitModes(t *testing.T) {
data, err := json.Marshal(SendMessageP{
ChatID: 42,
Text: "hello",
ParseMode: ParseMDV2,
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if !strings.Contains(string(data), `"parse_mode":"MarkdownV2"`) {
t.Fatalf("expected MarkdownV2 parse_mode, got %s", string(data))
}
}
+10
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method.
// See https://core.telegram.org/bots/api#setpassportdataerrors
type SetPassportDataErrorsP struct {
@@ -14,3 +16,11 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, erro
req := NewRequest[bool]("setPassportDataErrors", params)
return req.Do(api)
}
// SetPassportDataErrorsWithContext is the context-aware variant of SetPassportDataErrors.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setpassportdataerrors
func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrorsP) (bool, error) {
req := NewRequest[bool]("setPassportDataErrors", params)
return req.DoWithContext(ctx, api)
}
+37 -4
View File
@@ -1,12 +1,13 @@
package tgapi
import "context"
// SendInvoiceP holds parameters for the sendInvoice method.
// See https://core.telegram.org/bots/api#sendinvoice
type SendInvoiceP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Title string `json:"title"`
Description string `json:"description"`
@@ -47,6 +48,14 @@ func (api *API) SendInvoice(params SendInvoiceP) (Message, error) {
return req.Do(api)
}
// SendInvoiceWithContext is the context-aware variant of SendInvoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendinvoice
func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateInvoiceLinkP holds parameters for the createInvoiceLink method.
// See https://core.telegram.org/bots/api#createinvoicelink
type CreateInvoiceLinkP struct {
@@ -83,6 +92,14 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) {
return req.Do(api)
}
// CreateInvoiceLinkWithContext is the context-aware variant of CreateInvoiceLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createinvoicelink
func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLinkP) (string, error) {
req := NewRequest[string]("createInvoiceLink", params)
return req.DoWithContext(ctx, api)
}
// AnswerShippingQueryP holds parameters for the answerShippingQuery method.
// See https://core.telegram.org/bots/api#answershippingquery
type AnswerShippingQueryP struct {
@@ -100,6 +117,14 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) {
return req.Do(api)
}
// AnswerShippingQueryWithContext is the context-aware variant of AnswerShippingQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answershippingquery
func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQueryP) (bool, error) {
req := NewRequest[bool]("answerShippingQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
type AnswerPreCheckoutQueryP struct {
@@ -115,3 +140,11 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, er
req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.Do(api)
}
// AnswerPreCheckoutQueryWithContext is the context-aware variant of AnswerPreCheckoutQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQueryP) (bool, error) {
req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.DoWithContext(ctx, api)
}
+22 -62
View File
@@ -5,44 +5,35 @@ import (
"sync"
)
// workerPool — приватная структура, управляющая пулом воркеров.
// Внешний код не может создавать или напрямую взаимодействовать с этой структурой.
// Используется только через экспортируемые методы newWorkerPool, start, stop, submit.
type workerPool struct {
taskCh chan requestEnvelope // канал для принятия задач (буферизованный)
queueSize int // максимальный размер очереди
workers int // количество воркеров (горутин)
wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке
quit chan struct{} // канал для сигнала остановки
stopOnce sync.Once // гарантирует идемпотентную остановку пула
started bool // флаг, указывающий, запущен ли пул
stopped bool // флаг, указывающий, что пул остановлен
startedMu sync.Mutex // мьютекс для безопасного доступа к started
taskCh chan requestEnvelope
queueSize int
workers int
wg sync.WaitGroup
quit chan struct{}
stopOnce sync.Once
started bool
stopped bool
startedMu sync.Mutex
}
// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата.
// Используется только внутри пакета для передачи задач воркерам.
type requestEnvelope struct {
ctx context.Context // контекст конкретной задачи
doFunc func(context.Context) (any, error) // функция, выполняющая запрос
resultCh chan requestResult // канал, через который воркер вернёт результат
ctx context.Context
doFunc func(context.Context) (any, error)
resultCh chan requestResult
}
// requestResult — приватная структура, представляющая результат выполнения задачи.
// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult.
type requestResult struct {
value any // значение, возвращённое задачей
err error // ошибка, если возникла
value any
err error
}
// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди.
// Это единственный способ создать workerPool — внешний код не может создать его напрямую.
func newWorkerPool(workers int, queueSize int) *workerPool {
if workers <= 0 {
workers = 1 // защита от некорректных значений
workers = 1
}
if queueSize <= 0 {
queueSize = 100 // разумный дефолт
queueSize = 100
}
return &workerPool{
@@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool {
}
}
// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди.
// Метод идемпотентен: если пул уже запущен — ничего не делает.
// Должен вызываться перед первым вызовом submit.
func (p *workerPool) start() {
p.startedMu.Lock()
defer p.startedMu.Unlock()
if p.started {
return // уже запущен — ничего не делаем
return
}
p.started = true
// Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker() // запускаем горутину
go p.worker()
}
}
// stop останавливает пул воркеров.
// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач.
// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта.
func (p *workerPool) stop() {
p.stopOnce.Do(func() {
p.startedMu.Lock()
p.stopped = true
p.started = false
close(p.quit) // сигнал для всех воркеров — выйти из цикла
close(p.quit)
p.startedMu.Unlock()
p.wg.Wait() // ждём, пока все воркеры завершатся
p.wg.Wait()
})
}
// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат.
// Если очередь переполнена — возвращает ErrPoolQueueFull.
// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи.
// Контекст используется для отмены задачи, если клиент отменил запрос до отправки.
func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) {
p.startedMu.Lock()
if p.stopped || !p.started {
@@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any,
return nil, ErrPoolStopped
}
// Проверяем, не превышена ли очередь
if len(p.taskCh) >= p.queueSize {
p.startedMu.Unlock()
return nil, ErrPoolQueueFull
}
// Создаём канал для результата — буферизованный, чтобы не блокировать воркера
resultCh := make(chan requestResult, 1)
// Создаём обёртку задачи
envelope := requestEnvelope{
ctx: ctx,
doFunc: do,
resultCh: resultCh,
}
// Пытаемся отправить задачу в очередь
select {
case <-ctx.Done():
p.startedMu.Unlock()
// Клиент отменил операцию до отправки — возвращаем ошибку отмены
return nil, ctx.Err()
case p.taskCh <- envelope:
p.startedMu.Unlock()
// Успешно отправлено — возвращаем канал для чтения результата
return resultCh, nil
default:
p.startedMu.Unlock()
// Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай
return nil, ErrPoolQueueFull
}
}
// worker — приватная горутина, выполняющая задачи из очереди.
// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки.
// При получении задачи:
// - вызывает doFunc с контекстом
// - записывает результат в resultCh
// - закрывает канал, чтобы клиент мог прочитать и завершить
//
// После закрытия quit-канала — воркер завершает работу.
func (p *workerPool) worker() {
defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины
defer p.wg.Done()
for {
select {
case <-p.quit:
// Получен сигнал остановки — дренируем очередь и выходим.
// После stop() новые задачи не принимаются.
// Drain queued work after stop. No new tasks are accepted.
for {
select {
case envelope := <-p.taskCh:
@@ -162,14 +126,10 @@ func (p *workerPool) worker() {
}
func (p *workerPool) executeEnvelope(envelope requestEnvelope) {
// Выполняем задачу с переданным контекстом (клиентский или общий)
value, err := envelope.doFunc(envelope.ctx)
// Записываем результат в канал — не блокируем, т.к. буфер 1
envelope.resultCh <- requestResult{
value: value,
err: err,
}
// Закрываем канал — клиент знает, что результат пришёл и больше не будет
close(envelope.resultCh)
}
+62
View File
@@ -0,0 +1,62 @@
package tgapi
import (
"context"
"errors"
"testing"
)
func TestWorkerPoolSubmitAfterStop(t *testing.T) {
pool := newWorkerPool(1, 1)
pool.start()
pool.stop()
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return nil, nil
}); !errors.Is(err, ErrPoolStopped) {
t.Fatalf("expected ErrPoolStopped, got %v", err)
}
}
func TestWorkerPoolQueueFull(t *testing.T) {
pool := newWorkerPool(1, 1)
pool.start()
defer pool.stop()
started := make(chan struct{})
release := make(chan struct{})
firstResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
close(started)
<-release
return "first", nil
})
if err != nil {
t.Fatalf("first submit returned error: %v", err)
}
<-started
secondResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return "second", nil
})
if err != nil {
t.Fatalf("second submit returned error: %v", err)
}
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return "third", nil
}); !errors.Is(err, ErrPoolQueueFull) {
t.Fatalf("expected ErrPoolQueueFull, got %v", err)
}
close(release)
first := <-firstResult
if first.err != nil || first.value != "first" {
t.Fatalf("unexpected first result: %+v", first)
}
second := <-secondResult
if second.err != nil || second.value != "second" {
t.Fatalf("unexpected second result: %+v", second)
}
}
+34
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// GetStarTransactionsP holds parameters for the getStarTransactions method.
// See https://core.telegram.org/bots/api#getstartransactions
type GetStarTransactionsP struct {
@@ -14,6 +16,14 @@ func (api *API) GetMyStarBalance() (StarAmount, error) {
return req.Do(api)
}
// GetMyStarBalanceWithContext is the context-aware variant of GetMyStarBalance.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmystarbalance
func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, error) {
req := NewRequest[StarAmount]("getMyStarBalance", NoParams)
return req.DoWithContext(ctx, api)
}
// GetStarTransactions returns Telegram Star transactions for the bot.
// See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) {
@@ -21,6 +31,14 @@ func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactio
return req.Do(api)
}
// GetStarTransactionsWithContext is the context-aware variant of GetStarTransactions.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactionsP) (StarTransactions, error) {
req := NewRequest[StarTransactions]("getStarTransactions", params)
return req.DoWithContext(ctx, api)
}
// RefundStarPaymentP holds parameters for the refundStarPayment method.
// See https://core.telegram.org/bots/api#refundstarpayment
type RefundStarPaymentP struct {
@@ -36,6 +54,14 @@ func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) {
return req.Do(api)
}
// RefundStarPaymentWithContext is the context-aware variant of RefundStarPayment.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#refundstarpayment
func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPaymentP) (bool, error) {
req := NewRequest[bool]("refundStarPayment", params)
return req.DoWithContext(ctx, api)
}
// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method.
// See https://core.telegram.org/bots/api#edituserstarsubscription
type EditUserStarSubscriptionP struct {
@@ -51,3 +77,11 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool
req := NewRequest[bool]("editUserStarSubscription", params)
return req.Do(api)
}
// EditUserStarSubscriptionWithContext is the context-aware variant of EditUserStarSubscription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#edituserstarsubscription
func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscriptionP) (bool, error) {
req := NewRequest[bool]("editUserStarSubscription", params)
return req.DoWithContext(ctx, api)
}
+138
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SendStickerP holds parameters for the sendSticker method.
// See https://core.telegram.org/bots/api#sendsticker
type SendStickerP struct {
@@ -14,6 +16,10 @@ type SendStickerP struct {
ProtectContent bool `json:"protect_content,omitempty"`
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
MessageEffectID string `json:"message_effect_id,omitempty"`
SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"`
ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
}
// SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker.
@@ -23,6 +29,14 @@ func (api *API) SendSticker(params SendStickerP) (Message, error) {
return req.Do(api)
}
// SendStickerWithContext is the context-aware variant of SendSticker.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendsticker
func (api *API) SendStickerWithContext(ctx context.Context, params SendStickerP) (Message, error) {
req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetStickerSetP holds parameters for the getStickerSet method.
// See https://core.telegram.org/bots/api#getstickerset
type GetStickerSetP struct {
@@ -36,6 +50,14 @@ func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) {
return req.Do(api)
}
// GetStickerSetWithContext is the context-aware variant of GetStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getstickerset
func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSetP) (StickerSet, error) {
req := NewRequest[StickerSet]("getStickerSet", params)
return req.DoWithContext(ctx, api)
}
// GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method.
// See https://core.telegram.org/bots/api#getcustomemojistickers
type GetCustomEmojiStickersP struct {
@@ -49,6 +71,14 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke
return req.Do(api)
}
// GetCustomEmojiStickersWithContext is the context-aware variant of GetCustomEmojiStickers.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getcustomemojistickers
func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickersP) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getCustomEmojiStickers", params)
return req.DoWithContext(ctx, api)
}
// UploadStickerFileP holds parameters for the uploadStickerFile method.
// See https://core.telegram.org/bots/api#uploadstickerfile
type UploadStickerFileP struct {
@@ -68,6 +98,18 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil
return req.Do(uploader)
}
// UploadStickerFileWithContext is the context-aware variant of UploadStickerFile.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#uploadstickerfile
func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFileP, sticker UploaderFile) (File, error) {
uploader := NewUploader(api)
defer func() {
_ = uploader.Close()
}()
req := NewUploaderRequest[File]("uploadStickerFile", params, sticker.SetType(UploaderStickerType))
return req.DoWithContext(ctx, uploader)
}
// CreateNewStickerSetP holds parameters for the createNewStickerSet method.
// See https://core.telegram.org/bots/api#createnewstickerset
type CreateNewStickerSetP struct {
@@ -88,6 +130,14 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) {
return req.Do(api)
}
// CreateNewStickerSetWithContext is the context-aware variant of CreateNewStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createnewstickerset
func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSetP) (bool, error) {
req := NewRequest[bool]("createNewStickerSet", params)
return req.DoWithContext(ctx, api)
}
// AddStickerToSetP holds parameters for the addStickerToSet method.
// See https://core.telegram.org/bots/api#addstickertoset
type AddStickerToSetP struct {
@@ -104,6 +154,14 @@ func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) {
return req.Do(api)
}
// AddStickerToSetWithContext is the context-aware variant of AddStickerToSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#addstickertoset
func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSetP) (bool, error) {
req := NewRequest[bool]("addStickerToSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method.
// See https://core.telegram.org/bots/api#setstickerpositioninset
type SetStickerPositionInSetP struct {
@@ -119,6 +177,14 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool,
return req.Do(api)
}
// SetStickerPositionInSetWithContext is the context-aware variant of SetStickerPositionInSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickerpositioninset
func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSetP) (bool, error) {
req := NewRequest[bool]("setStickerPositionInSet", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method.
// See https://core.telegram.org/bots/api#deletestickerfromset
type DeleteStickerFromSetP struct {
@@ -133,6 +199,14 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error)
return req.Do(api)
}
// DeleteStickerFromSetWithContext is the context-aware variant of DeleteStickerFromSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestickerfromset
func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerFromSet", params)
return req.DoWithContext(ctx, api)
}
// ReplaceStickerInSetP holds parameters for the replaceStickerInSet method.
// See https://core.telegram.org/bots/api#replacestickerinset
type ReplaceStickerInSetP struct {
@@ -150,6 +224,14 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) {
return req.Do(api)
}
// ReplaceStickerInSetWithContext is the context-aware variant of ReplaceStickerInSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#replacestickerinset
func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSetP) (bool, error) {
req := NewRequest[bool]("replaceStickerInSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerEmojiListP holds parameters for the setStickerEmojiList method.
// See https://core.telegram.org/bots/api#setstickeremojilist
type SetStickerEmojiListP struct {
@@ -165,6 +247,14 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) {
return req.Do(api)
}
// SetStickerEmojiListWithContext is the context-aware variant of SetStickerEmojiList.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickeremojilist
func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiListP) (bool, error) {
req := NewRequest[bool]("setStickerEmojiList", params)
return req.DoWithContext(ctx, api)
}
// SetStickerKeywordsP holds parameters for the setStickerKeywords method.
// See https://core.telegram.org/bots/api#setstickerkeywords
type SetStickerKeywordsP struct {
@@ -180,6 +270,14 @@ func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) {
return req.Do(api)
}
// SetStickerKeywordsWithContext is the context-aware variant of SetStickerKeywords.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickerkeywords
func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywordsP) (bool, error) {
req := NewRequest[bool]("setStickerKeywords", params)
return req.DoWithContext(ctx, api)
}
// SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method.
// See https://core.telegram.org/bots/api#setstickermaskposition
type SetStickerMaskPositionP struct {
@@ -195,6 +293,14 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, er
return req.Do(api)
}
// SetStickerMaskPositionWithContext is the context-aware variant of SetStickerMaskPosition.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickermaskposition
func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPositionP) (bool, error) {
req := NewRequest[bool]("setStickerMaskPosition", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetTitleP holds parameters for the setStickerSetTitle method.
// See https://core.telegram.org/bots/api#setstickersettitle
type SetStickerSetTitleP struct {
@@ -210,6 +316,14 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) {
return req.Do(api)
}
// SetStickerSetTitleWithContext is the context-aware variant of SetStickerSetTitle.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickersettitle
func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitleP) (bool, error) {
req := NewRequest[bool]("setStickerSetTitle", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setstickersetthumbnail
type SetStickerSetThumbnailP struct {
@@ -227,6 +341,14 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, er
return req.Do(api)
}
// SetStickerSetThumbnailWithContext is the context-aware variant of SetStickerSetThumbnail.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickersetthumbnail
func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnailP) (bool, error) {
req := NewRequest[bool]("setStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
type SetCustomEmojiStickerSetThumbnailP struct {
@@ -242,6 +364,14 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe
return req.Do(api)
}
// SetCustomEmojiStickerSetThumbnailWithContext is the context-aware variant of SetCustomEmojiStickerSetThumbnail.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnailP) (bool, error) {
req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerSetP holds parameters for the deleteStickerSet method.
// See https://core.telegram.org/bots/api#deletestickerset
type DeleteStickerSetP struct {
@@ -255,3 +385,11 @@ func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params)
return req.Do(api)
}
// DeleteStickerSetWithContext is the context-aware variant of DeleteStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestickerset
func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params)
return req.DoWithContext(ctx, api)
}
+101 -24
View File
@@ -1,9 +1,14 @@
package tgapi
import "encoding/json"
// UpdateType represents the type of incoming update.
type UpdateType string
const (
// UpdateTypeUnknown marks an update whose payload does not match a known Telegram update kind.
UpdateTypeUnknown UpdateType = "unknown"
// UpdateTypeMessage is a regular message update.
UpdateTypeMessage UpdateType = "message"
// UpdateTypeEditedMessage is an edited message update.
@@ -23,8 +28,8 @@ const (
UpdateTypeBusinessMessage UpdateType = "business_message"
// UpdateTypeEditedBusinessMessage is an edited business message update.
UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message"
// UpdateTypeDeletedBusinessMessage is a deleted business message update.
UpdateTypeDeletedBusinessMessage UpdateType = "deleted_business_message"
// UpdateTypeDeletedBusinessMessages is a deleted business messages update.
UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages"
// UpdateTypeInlineQuery is an inline query update.
UpdateTypeInlineQuery UpdateType = "inline_query"
@@ -57,23 +62,25 @@ const (
// Update represents an incoming update from Telegram.
// See https://core.telegram.org/bots/api#update
type Update struct {
Type UpdateType `json:"-"`
UpdateID int `json:"update_id"`
Message *Message `json:"message,omitempty"`
EditedMessage *Message `json:"edited_message,omitempty"`
ChannelPost *Message `json:"channel_post,omitempty"`
EditedChannelPost *Message `json:"edited_channel_post,omitempty"`
BusinessConnection *BusinessConnection `json:"business_connection,omitempty"`
BusinessMessage *Message `json:"business_message,omitempty"`
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
DeletedBusinessMessage *Message `json:"deleted_business_messages,omitempty"`
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
BusinessConnection *BusinessConnection `json:"business_connection,omitempty"`
BusinessMessage *Message `json:"business_message,omitempty"`
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
InlineQuery *InlineQuery `json:"inline_query,omitempty"`
ChosenInlineResult *ChosenInlineResult `json:"chosen_inline_result,omitempty"`
CallbackQuery *CallbackQuery `json:"callback_query,omitempty"`
ShippingQuery ShippingQuery `json:"shipping_query,omitempty"`
ShippingQuery *ShippingQuery `json:"shipping_query,omitempty"`
PreCheckoutQuery *PreCheckoutQuery `json:"pre_checkout_query,omitempty"`
PurchasedPaidMedia *PaidMediaPurchased `json:"purchased_paid_media,omitempty"`
@@ -86,6 +93,74 @@ type Update struct {
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
}
// UnmarshalJSON decodes an update and derives its Type from the populated payload field.
func (u *Update) UnmarshalJSON(data []byte) error {
type Alias Update
var aux Alias
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*u = Update(aux)
switch {
case u.Message != nil:
u.Type = UpdateTypeMessage
case u.EditedMessage != nil:
u.Type = UpdateTypeEditedMessage
case u.ChannelPost != nil:
u.Type = UpdateTypeChannelPost
case u.EditedChannelPost != nil:
u.Type = UpdateTypeEditedChannelPost
case u.BusinessConnection != nil:
u.Type = UpdateTypeBusinessConnection
case u.BusinessMessage != nil:
u.Type = UpdateTypeBusinessMessage
case u.EditedBusinessMessage != nil:
u.Type = UpdateTypeEditedBusinessMessage
case u.DeletedBusinessMessages != nil:
u.Type = UpdateTypeDeletedBusinessMessages
case u.MessageReaction != nil:
u.Type = UpdateTypeMessageReaction
case u.MessageReactionCount != nil:
u.Type = UpdateTypeMessageReactionCount
case u.InlineQuery != nil:
u.Type = UpdateTypeInlineQuery
case u.ChosenInlineResult != nil:
u.Type = UpdateTypeChosenInlineResult
case u.CallbackQuery != nil:
u.Type = UpdateTypeCallbackQuery
case u.ShippingQuery != nil:
u.Type = UpdateTypeShippingQuery
case u.PreCheckoutQuery != nil:
u.Type = UpdateTypePreCheckoutQuery
case u.PurchasedPaidMedia != nil:
u.Type = UpdateTypePurchasedPaidMedia
case u.Poll != nil:
u.Type = UpdateTypePoll
case u.PollAnswer != nil:
u.Type = UpdateTypePollAnswer
case u.MyChatMember != nil:
u.Type = UpdateTypeMyChatMember
case u.ChatMember != nil:
u.Type = UpdateTypeChatMember
case u.ChatJoinRequest != nil:
u.Type = UpdateTypeChatJoinRequest
case u.ChatBoost != nil:
u.Type = UpdateTypeChatBoost
case u.RemovedChatBoost != nil:
u.Type = UpdateTypeRemovedChatBoost
default:
u.Type = UpdateTypeUnknown
}
return nil
}
// InlineQuery represents an incoming inline query.
// See https://core.telegram.org/bots/api#inlinequery
type InlineQuery struct {
@@ -317,19 +392,19 @@ type GiftBackground struct {
// Gift represents a gift that can be sent.
type Gift struct {
ID string `json:"id"`
Sticker Sticker `json:"sticker"`
StarCount int `json:"star_count"`
UpdateStarCount *int `json:"update_star_count,omitempty"`
IsPremium *bool `json:"is_premium,omitempty"`
HasColors *bool `json:"has_colors,omitempty"`
TotalCount *int `json:"total_count,omitempty"`
RemainingCount *int `json:"remaining_count,omitempty"`
PersonalTotalCount *int `json:"personal_total_count,omitempty"`
PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"`
Background GiftBackground `json:"background,omitempty"`
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
PublisherChat *Chat `json:"publisher_chat,omitempty"`
ID string `json:"id"`
Sticker Sticker `json:"sticker"`
StarCount int `json:"star_count"`
UpdateStarCount *int `json:"update_star_count,omitempty"`
IsPremium *bool `json:"is_premium,omitempty"`
HasColors *bool `json:"has_colors,omitempty"`
TotalCount *int `json:"total_count,omitempty"`
RemainingCount *int `json:"remaining_count,omitempty"`
PersonalTotalCount *int `json:"personal_total_count,omitempty"`
PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"`
Background *GiftBackground `json:"background,omitempty"`
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
PublisherChat *Chat `json:"publisher_chat,omitempty"`
}
// Gifts represents a list of gifts.
@@ -341,8 +416,10 @@ type Gifts struct {
type OwnedGiftType string
const (
// OwnedGiftRegularType identifies a regular owned gift.
OwnedGiftRegularType OwnedGiftType = "regular"
OwnedGiftUniqueType OwnedGiftType = "unique"
// OwnedGiftUniqueType identifies a unique owned gift.
OwnedGiftUniqueType OwnedGiftType = "unique"
)
// OwnedGift represents a gift owned by a user or chat.
@@ -354,7 +431,7 @@ type OwnedGift struct {
// Fields specific to "regular" type
Gift Gift `json:"gift"`
SenderUser User `json:"sender_user,omitempty"`
SenderUser *User `json:"sender_user,omitempty"`
Text string `json:"text,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
+116
View File
@@ -0,0 +1,116 @@
package tgapi
import (
"encoding/json"
"strings"
"testing"
)
func TestUpdateUnmarshalSetsType(t *testing.T) {
tests := []struct {
name string
body string
want UpdateType
}{
{
name: "deleted business messages",
body: `{
"update_id": 1,
"deleted_business_messages": {
"business_connection_id": "conn",
"chat": {"id": 42, "type": "private"},
"message_ids": [3, 5]
}
}`,
want: UpdateTypeDeletedBusinessMessages,
},
{
name: "callback query",
body: `{
"update_id": 2,
"callback_query": {
"id": "cb",
"from": {"id": 1, "is_bot": false, "first_name": "Test"},
"chat_instance": "instance",
"data": "payload"
}
}`,
want: UpdateTypeCallbackQuery,
},
{
name: "chat boost",
body: `{
"update_id": 3,
"chat_boost": {
"chat": {"id": -1001, "type": "supergroup", "title": "Boosted"},
"boost": {
"boost_id": "boost-1",
"add_date": 1735689600,
"expiration_date": 1738291600,
"source": {
"source": "premium",
"user": {"id": 1, "is_bot": false, "first_name": "Test"}
}
}
}
}`,
want: UpdateTypeChatBoost,
},
{
name: "unknown",
body: `{"update_id":4}`,
want: UpdateTypeUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var update Update
if err := json.Unmarshal([]byte(tt.body), &update); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if update.Type != tt.want {
t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
}
if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" {
t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1")
}
})
}
}
func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) {
update := Update{
UpdateID: 1,
Type: UpdateTypeCallbackQuery,
CallbackQuery: &CallbackQuery{
ID: "cb",
From: User{ID: 1, FirstName: "Test"},
ChatInstance: "instance",
Data: "payload",
},
}
data, err := json.Marshal(update)
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
got := string(data)
if strings.Contains(got, `"type"`) {
t.Fatalf("unexpected synthetic type field, got %s", got)
}
}
func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) {
var update Update
if err := json.Unmarshal([]byte(`{"update_id":1}`), &update); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if update.ShippingQuery != nil {
t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery)
}
if update.Type != UpdateTypeUnknown {
t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type)
}
}
+16 -12
View File
@@ -7,6 +7,7 @@ import (
"mime/multipart"
"net/http"
"path/filepath"
"strings"
"time"
"git.nix13.pw/scuroneko/laniakea/utils"
@@ -30,6 +31,8 @@ const (
UploaderThumbnailType UploaderFileType = "thumbnail"
// UploaderStickerType is the multipart field name for sticker uploads.
UploaderStickerType UploaderFileType = "sticker"
// UploaderCertificateType is the multipart field name for webhook certificate uploads.
UploaderCertificateType UploaderFileType = "certificate"
)
// UploaderFileType represents the Telegram form field name for a file upload.
@@ -67,8 +70,7 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader {
logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER")
logger.AddWriter(logger.CreateJsonStdoutWriter())
logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
return &Uploader{api, logger}
}
@@ -80,8 +82,12 @@ func (u *Uploader) Close() error { return u.logger.Close() }
// See https://core.telegram.org/bots/api
func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
// UploaderRequest is a multipart file upload request to the Telegram API.
// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one.
// UploaderRequest is a low-level multipart upload request wrapper.
//
// Prefer method-specific helpers such as SendPhoto or SetWebhook. UploaderRequest
// is intended for advanced use cases where callers manage the method name, files,
// and request/response types themselves. In that sense it is an unsafe escape
// hatch compared with the typed uploader API.
type UploaderRequest[R, P any] struct {
method string
files []UploaderFile
@@ -89,16 +95,17 @@ type UploaderRequest[R, P any] struct {
chatId int64
}
// NewUploaderRequest creates a new multipart upload request with no associated chat ID.
// NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0}
}
// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID.
// NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
// The chat ID is used for per-chat rate limiting.
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId}
}
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
var zero R
@@ -126,7 +133,6 @@ func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R,
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", fmt.Sprintf("Laniakea/%s", utils.VersionString))
req.Header.Set("Accept-Encoding", "gzip")
req.ContentLength = int64(buf.Len())
up.logger.Debugln("UPLOADER REQ", r.method)
@@ -204,8 +210,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up)
}
// prepareMultipart builds a multipart form body from the given files and params.
// Params are encoded via utils.Encode. The writer boundary is finalized before returning.
// Internal helper that builds a finalized multipart body from files and params.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
@@ -238,10 +243,9 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil
}
// uploaderTypeByExt infers the Telegram upload field name from a file extension.
// Falls back to UploaderDocumentType for unrecognized extensions.
// Internal helper that infers an upload field name from a file extension.
func uploaderTypeByExt(filename string) UploaderFileType {
ext := filepath.Ext(filename)
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".bmp":
return UploaderPhotoType
+159
View File
@@ -0,0 +1,159 @@
package tgapi
import (
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"strings"
"testing"
)
func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *testing.T) {
var (
gotPath string
gotAcceptEncoding string
gotFields map[string]string
gotFileName string
gotFileData []byte
roundTripErr error
)
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
gotAcceptEncoding = req.Header.Get("Accept-Encoding")
gotFields, gotFileName, gotFileData, roundTripErr = readMultipartRequest(req)
if roundTripErr != nil {
roundTripErr = fmt.Errorf("readMultipartRequest: %w", roundTripErr)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
uploader := NewUploader(api)
defer func() {
if err := uploader.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
msg, err := uploader.SendPhoto(
UploadPhotoP{
ChatID: 42,
CaptionEntities: []MessageEntity{{
Type: MessageEntityBold,
Offset: 0,
Length: 4,
}},
ReplyMarkup: &ReplyMarkup{
InlineKeyboard: [][]InlineKeyboardButton{{
{Text: "A", CallbackData: "b"},
}},
},
},
NewUploaderFile("photo.jpg", []byte("img")),
)
if err != nil {
t.Fatalf("SendPhoto returned error: %v", err)
}
if msg.MessageID != 5 {
t.Fatalf("unexpected message id: %d", msg.MessageID)
}
if roundTripErr != nil {
t.Fatalf("multipart parse failed: %v", roundTripErr)
}
if gotPath != "/bottoken/sendPhoto" {
t.Fatalf("unexpected request path: %s", gotPath)
}
if gotAcceptEncoding != "" {
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
}
if got := gotFields["chat_id"]; got != "42" {
t.Fatalf("chat_id mismatch: %q", got)
}
if got := gotFields["caption_entities"]; got != `[{"type":"bold","offset":0,"length":4}]` {
t.Fatalf("caption_entities mismatch: %q", got)
}
if got := gotFields["reply_markup"]; got != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` {
t.Fatalf("reply_markup mismatch: %q", got)
}
if gotFileName != "photo.jpg" {
t.Fatalf("unexpected file name: %q", gotFileName)
}
if string(gotFileData) != "img" {
t.Fatalf("unexpected file content: %q", string(gotFileData))
}
}
func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
tests := []struct {
name string
filename string
want UploaderFileType
}{
{name: "uppercase photo", filename: "PHOTO.JPG", want: UploaderPhotoType},
{name: "uppercase voice", filename: "voice.OGG", want: UploaderVoiceType},
{name: "unknown defaults to document", filename: "archive.BIN", want: UploaderDocumentType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := NewUploaderFile(tt.filename, []byte("x"))
if file.field != tt.want {
t.Fatalf("unexpected uploader field: got %q want %q", file.field, tt.want)
}
})
}
}
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
return nil, "", nil, err
}
reader := multipart.NewReader(req.Body, params["boundary"])
fields := make(map[string]string)
var fileName string
var fileData []byte
for {
part, err := reader.NextPart()
if err == io.EOF {
return fields, fileName, fileData, nil
}
if err != nil {
return nil, "", nil, err
}
data, err := io.ReadAll(part)
if err != nil {
return nil, "", nil, err
}
if part.FileName() != "" {
fileName = part.FileName()
fileData = data
continue
}
fields[part.FormName()] = string(data)
}
}
+110
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// UploadPhotoP holds parameters for uploading a photo using the Uploader.
// See https://core.telegram.org/bots/api#sendphoto
type UploadPhotoP struct {
@@ -32,6 +34,16 @@ func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, e
return req.Do(u)
}
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendphoto
func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhotoP, file UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file)
return req.DoWithContext(ctx, u)
}
// UploadAudioP holds parameters for uploading an audio file using the Uploader.
// See https://core.telegram.org/bots/api#sendaudio
type UploadAudioP struct {
@@ -66,6 +78,16 @@ func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Messag
return req.Do(u)
}
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendaudio
func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudioP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadDocumentP holds parameters for uploading a document using the Uploader.
// See https://core.telegram.org/bots/api#senddocument
type UploadDocumentP struct {
@@ -97,6 +119,16 @@ func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (
return req.Do(u)
}
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddocument
func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocumentP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVideoP holds parameters for uploading a video using the Uploader.
// See https://core.telegram.org/bots/api#sendvideo
type UploadVideoP struct {
@@ -135,6 +167,16 @@ func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Messag
return req.Do(u)
}
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideo
func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideoP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadAnimationP holds parameters for uploading an animation using the Uploader.
// See https://core.telegram.org/bots/api#sendanimation
type UploadAnimationP struct {
@@ -171,6 +213,16 @@ func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile)
return req.Do(u)
}
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendanimation
func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimationP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVoiceP holds parameters for uploading a voice note using the Uploader.
// See https://core.telegram.org/bots/api#sendvoice
type UploadVoiceP struct {
@@ -202,6 +254,16 @@ func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Messag
return req.Do(u)
}
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvoice
func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoiceP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader.
// See https://core.telegram.org/bots/api#sendvideonote
type UploadVideoNoteP struct {
@@ -231,6 +293,16 @@ func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile)
return req.Do(u)
}
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideonote
func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNoteP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader.
// See https://core.telegram.org/bots/api#setchatphoto
type UploadChatPhotoP struct {
@@ -244,3 +316,41 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bo
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.Do(u)
}
// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatphoto
func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhotoP, photo UploaderFile) (bool, error) {
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.DoWithContext(ctx, u)
}
// UploadSetWebhookP holds multipart parameters for the setWebhook method.
// Use this type when uploading a self-signed certificate file.
// See https://core.telegram.org/bots/api#setwebhook
type UploadSetWebhookP struct {
URL string `json:"url"`
IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
SecretToken string `json:"secret_token,omitempty"`
}
// SetWebhook uploads a certificate and sets a webhook URL.
// certificate maps to the multipart field \"certificate\".
// See https://core.telegram.org/bots/api#setwebhook
func (u *Uploader) SetWebhook(params UploadSetWebhookP, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.Do(u)
}
// SetWebhookWithContext is the context-aware variant of SetWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setwebhook
func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhookP, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.DoWithContext(ctx, u)
}
+34
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method.
// See https://core.telegram.org/bots/api#getuserprofilephotos
type GetUserProfilePhotosP struct {
@@ -15,6 +17,14 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP
return req.Do(api)
}
// GetUserProfilePhotosWithContext is the context-aware variant of GetUserProfilePhotos.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getuserprofilephotos
func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotosP) (UserProfilePhotos, error) {
req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params)
return req.DoWithContext(ctx, api)
}
// GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method.
// See https://core.telegram.org/bots/api#getuserprofileaudios
type GetUserProfileAudiosP struct {
@@ -30,6 +40,14 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA
return req.Do(api)
}
// GetUserProfileAudiosWithContext is the context-aware variant of GetUserProfileAudios.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getuserprofileaudios
func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudiosP) (UserProfileAudios, error) {
req := NewRequest[UserProfileAudios]("getUserProfileAudios", params)
return req.DoWithContext(ctx, api)
}
// SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method.
// See https://core.telegram.org/bots/api#setuseremojistatus
type SetUserEmojiStatusP struct {
@@ -46,6 +64,14 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) {
return req.Do(api)
}
// SetUserEmojiStatusWithContext is the context-aware variant of SetUserEmojiStatus.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setuseremojistatus
func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatusP) (bool, error) {
req := NewRequest[bool]("setUserEmojiStatus", params)
return req.DoWithContext(ctx, api)
}
// GetUserGiftsP holds parameters for the GetUserGifts method.
// See https://core.telegram.org/bots/api#getusergifts
type GetUserGiftsP struct {
@@ -66,3 +92,11 @@ func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.Do(api)
}
// GetUserGiftsWithContext is the context-aware variant of GetUserGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getusergifts
func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.DoWithContext(ctx, api)
}
+9 -5
View File
@@ -53,11 +53,15 @@ func EscapePunctuation(s string) string {
return s
}
// Version constants mirror values from the internal utils/version package.
const (
// VersionString re-exports the module version string.
VersionString = utils.VersionString
VersionMajor = utils.VersionMajor
VersionMinor = utils.VersionMinor
VersionPatch = utils.VersionPatch
VersionBeta = utils.VersionBeta
// VersionMajor re-exports the module major version.
VersionMajor = utils.VersionMajor
// VersionMinor re-exports the module minor version.
VersionMinor = utils.VersionMinor
// VersionPatch re-exports the module patch version.
VersionPatch = utils.VersionPatch
// VersionBeta re-exports the module prerelease counter.
VersionBeta = utils.VersionBeta
)
+5 -7
View File
@@ -9,6 +9,7 @@ import (
"golang.org/x/time/rate"
)
// ErrDropOverflow is returned when drop mode rejects a rate-limited request.
var ErrDropOverflow = errors.New("drop overflow limit")
// RateLimiter implements per-chat and global rate limiting with optional blocking.
@@ -102,7 +103,7 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error {
return chatLimiter.Wait(ctx)
}
// getGlobalLimiter returns the global limiter safely under read lock.
// Internal helper that returns the global limiter under read lock.
func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter {
rl.globalMu.RLock()
defer rl.globalMu.RUnlock()
@@ -190,8 +191,7 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6
return nil
}
// waitForGlobalUnlock blocks until global cooldown expires or context is done.
// Does not check token bucket — only cooldown.
// Internal helper that waits for the global cooldown to expire.
func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
rl.globalMu.RLock()
until := rl.globalLockUntil
@@ -209,8 +209,7 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
}
}
// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done.
// Does not check token bucket — only cooldown.
// Internal helper that waits for a chat-specific cooldown to expire.
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
rl.chatMu.RLock()
until, ok := rl.chatLocks[chatID]
@@ -228,8 +227,7 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
}
}
// getChatLimiter returns the rate limiter for the given chat, creating it if needed.
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
// Internal helper that returns or creates a per-chat limiter.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
rl.chatMu.Lock()
defer rl.chatMu.Unlock()
+41
View File
@@ -0,0 +1,41 @@
package utils
import (
"context"
"errors"
"testing"
"time"
)
func TestRateLimiterCheckDropOverflowHonorsGlobalLock(t *testing.T) {
rl := NewRateLimiter()
rl.SetGlobalLock(1)
if err := rl.Check(context.Background(), true, 0); !errors.Is(err, ErrDropOverflow) {
t.Fatalf("expected ErrDropOverflow, got %v", err)
}
}
func TestRateLimiterChatLocksAreScopedPerChat(t *testing.T) {
rl := NewRateLimiter()
rl.SetChatLock(42, 1)
if rl.Allow(42) {
t.Fatal("expected locked chat to be rejected")
}
if !rl.Allow(7) {
t.Fatal("expected unrelated chat to remain allowed")
}
}
func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) {
rl := NewRateLimiter()
rl.SetGlobalLock(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := rl.GlobalWait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
}
+70 -94
View File
@@ -1,8 +1,8 @@
package utils
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"reflect"
"slices"
@@ -12,12 +12,8 @@ import (
// Encode writes struct fields into multipart form-data using json tags as field names.
func Encode[T any](w *multipart.Writer, req T) error {
v := reflect.ValueOf(req)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
v := unwrapMultipartValue(reflect.ValueOf(req))
if !v.IsValid() || v.Kind() != reflect.Struct {
return fmt.Errorf("req must be a struct")
}
@@ -33,6 +29,9 @@ func Encode[T any](w *multipart.Writer, req T) error {
parts := strings.Split(jsonTag, ",")
fieldName := parts[0]
if fieldName == "" {
fieldName = fieldType.Name
}
if fieldName == "-" {
continue
}
@@ -43,96 +42,73 @@ func Encode[T any](w *multipart.Writer, req T) error {
continue
}
var (
fw io.Writer
err error
)
switch field.Kind() {
case reflect.String:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(field.String()))
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatInt(field.Int(), 10)))
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatUint(field.Uint(), 10)))
}
case reflect.Float32:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 32)))
}
case reflect.Float64:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatFloat(field.Float(), 'f', -1, 64)))
}
case reflect.Bool:
fw, err = w.CreateFormField(fieldName)
if err == nil {
_, err = fw.Write([]byte(strconv.FormatBool(field.Bool())))
}
case reflect.Slice:
if field.Type().Elem().Kind() == reflect.Uint8 && !field.IsNil() {
// Handle []byte as file upload (e.g., thumbnail)
filename := fieldType.Tag.Get("filename")
if filename == "" {
filename = fieldName
}
fw, err = w.CreateFormFile(fieldName, filename)
if err == nil {
_, err = fw.Write(field.Bytes())
}
} else if !field.IsNil() {
// Handle []string, []int, etc. — send as multiple fields with same name
for j := 0; j < field.Len(); j++ {
elem := field.Index(j)
fw, err = w.CreateFormField(fieldName)
if err != nil {
break
}
switch elem.Kind() {
case reflect.String:
_, err = fw.Write([]byte(elem.String()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
_, err = fw.Write([]byte(strconv.FormatInt(elem.Int(), 10)))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
_, err = fw.Write([]byte(strconv.FormatUint(elem.Uint(), 10)))
case reflect.Bool:
_, err = fw.Write([]byte(strconv.FormatBool(elem.Bool())))
case reflect.Float32:
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 32)))
case reflect.Float64:
_, err = fw.Write([]byte(strconv.FormatFloat(elem.Float(), 'f', -1, 64)))
default:
continue
}
if err != nil {
break
}
}
}
case reflect.Struct:
// Don't serialize structs as JSON — flatten them!
// Telegram doesn't support nested JSON in form-data.
// If you need nested data, use separate fields (e.g., ParseMode, CaptionEntities)
// This is a design choice — you should avoid nested structs in params.
return fmt.Errorf("nested structs are not supported in params — use flat fields")
}
if err != nil {
if err := writeMultipartField(w, fieldName, fieldType.Tag.Get("filename"), field); err != nil {
return err
}
}
return nil
}
func unwrapMultipartValue(v reflect.Value) reflect.Value {
for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) {
if v.IsNil() {
return reflect.Value{}
}
v = v.Elem()
}
return v
}
func writeMultipartField(w *multipart.Writer, fieldName, filename string, field reflect.Value) error {
value := unwrapMultipartValue(field)
if !value.IsValid() {
return nil
}
switch value.Kind() {
case reflect.String:
return writeMultipartValue(w, fieldName, []byte(value.String()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatInt(value.Int(), 10)))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatUint(value.Uint(), 10)))
case reflect.Float32:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatFloat(value.Float(), 'f', -1, 32)))
case reflect.Float64:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatFloat(value.Float(), 'f', -1, 64)))
case reflect.Bool:
return writeMultipartValue(w, fieldName, []byte(strconv.FormatBool(value.Bool())))
case reflect.Slice:
if value.Type().Elem().Kind() == reflect.Uint8 {
if filename == "" {
filename = fieldName
}
fw, err := w.CreateFormFile(fieldName, filename)
if err != nil {
return err
}
_, err = fw.Write(value.Bytes())
return err
}
}
// Telegram expects nested objects and arrays in multipart requests as JSON strings.
data, err := json.Marshal(value.Interface())
if err != nil {
return err
}
if string(data) == "null" {
return nil
}
return writeMultipartValue(w, fieldName, data)
}
func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) error {
fw, err := w.CreateFormField(fieldName)
if err != nil {
return err
}
_, err = fw.Write(value)
return err
}
+85
View File
@@ -0,0 +1,85 @@
package utils_test
import (
"bytes"
"io"
"mime/multipart"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
)
type multipartEncodeParams struct {
ChatID int64 `json:"chat_id"`
MessageThreadID *int `json:"message_thread_id,omitempty"`
ReplyMarkup *tgapi.ReplyMarkup `json:"reply_markup,omitempty"`
CaptionEntities []tgapi.MessageEntity `json:"caption_entities,omitempty"`
ReplyParameters *tgapi.ReplyParameters `json:"reply_parameters,omitempty"`
}
func TestEncodeMultipartJSONFields(t *testing.T) {
threadID := 7
params := multipartEncodeParams{
ChatID: 42,
MessageThreadID: &threadID,
ReplyMarkup: &tgapi.ReplyMarkup{
InlineKeyboard: [][]tgapi.InlineKeyboardButton{{
{Text: "A", CallbackData: "b"},
}},
},
CaptionEntities: []tgapi.MessageEntity{{
Type: tgapi.MessageEntityBold,
Offset: 0,
Length: 4,
}},
}
body := bytes.NewBuffer(nil)
writer := multipart.NewWriter(body)
if err := utils.Encode(writer, params); err != nil {
t.Fatalf("Encode returned error: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("writer.Close returned error: %v", err)
}
got := readMultipartFields(t, body.Bytes(), writer.Boundary())
if got["chat_id"] != "42" {
t.Fatalf("chat_id mismatch: %q", got["chat_id"])
}
if got["message_thread_id"] != "7" {
t.Fatalf("message_thread_id mismatch: %q", got["message_thread_id"])
}
if got["reply_markup"] != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` {
t.Fatalf("reply_markup mismatch: %q", got["reply_markup"])
}
if got["caption_entities"] != `[{"type":"bold","offset":0,"length":4}]` {
t.Fatalf("caption_entities mismatch: %q", got["caption_entities"])
}
if _, ok := got["reply_parameters"]; ok {
t.Fatalf("reply_parameters should be omitted when nil")
}
}
func readMultipartFields(t *testing.T, body []byte, boundary string) map[string]string {
t.Helper()
reader := multipart.NewReader(bytes.NewReader(body), boundary)
fields := make(map[string]string)
for {
part, err := reader.NextPart()
if err == io.EOF {
return fields
}
if err != nil {
t.Fatalf("NextPart returned error: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
fields[part.FormName()] = string(data)
}
}
+26
View File
@@ -14,3 +14,29 @@ func GetLoggerLevel() slog.LogLevel {
}
return level
}
// CreateLogger creates a logger with the shared default policy:
// JSON stdout output, provided prefix, and provided level.
func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger {
logger := slog.CreateLogger().Level(level)
if prefix != "" {
logger.Prefix(prefix)
}
logger.AddWriter(logger.CreateJsonStdoutWriter())
return logger
}
// CreateFileLogger creates a logger with the shared default policy and appends
// file output to the provided path.
//
// The returned logger is always non-nil. When file writer creation fails, the
// logger still writes to stdout and the error is returned to the caller.
func CreateFileLogger(prefix string, level slog.LogLevel, filePath string) (*slog.Logger, error) {
logger := CreateLogger(prefix, level)
fileWriter, err := logger.CreateTextFileWriter(filePath)
if err != nil {
return logger, err
}
logger.AddWriter(fileWriter)
return logger, nil
}
+34
View File
@@ -0,0 +1,34 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
"git.nix13.pw/scuroneko/slog"
)
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "main.log")
logger, err := CreateFileLogger("TEST", slog.DEBUG, logPath)
if err != nil {
t.Fatalf("CreateFileLogger returned error: %v", err)
}
logger.Infoln("hello from file logger")
if err := logger.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !strings.Contains(string(data), "hello from file logger") {
t.Fatalf("expected log message in file, got %q", string(data))
}
if !strings.Contains(string(data), "[TEST]") {
t.Fatalf("expected prefix in file, got %q", string(data))
}
}
+10 -5
View File
@@ -1,9 +1,14 @@
package utils
const (
VersionString = "1.0.0-beta.22"
VersionMajor = 1
VersionMinor = 0
VersionPatch = 0
VersionBeta = 22
// VersionString is the module version string.
VersionString = "1.0.0-rc.11"
// VersionMajor is the module major version.
VersionMajor = 1
// VersionMinor is the module minor version.
VersionMinor = 0
// VersionPatch is the module patch version.
VersionPatch = 0
// VersionBeta is the prerelease counter for the current version.
VersionBeta = 11
)